Skip to content

Commit a8a1c51

Browse files
committed
Add server-plugin-defined nav pages
Adds a new server-plugin-driven concept of "custom pages" — admin declares pages in PagesConfig (id, title, icon, position, rows), each becomes a nav-drawer entry slotted in at one of: AfterHome, AfterFavorites, AfterDiscover, AfterLibraries, End. Pages within the same position keep their YAML order. Client fetches the page list at user switch and a single page's rows on demand via the new ServerPluginApi.fetchPages / fetchPage. Rows are rendered through the existing HomePageContent composable, so the visual experience (backdrop, header, focus handling) matches the home screen. To avoid re-fetching every time the user navigates to a page (the nav back stack pops and re-pushes the entry, recreating the ViewModel) a small CustomPageRowsCache singleton holds the latest fetched rows per (userId, pageId). The cache is cleared on user switch. The admin-supplied icon string is rendered in two ways: - http(s):// URL → loaded via Coil's AsyncImage (PNG / SVG / JPG) - any other name → looked up in a small Material Icons whitelist (Home, Star, Settings, ...) and rendered as an ImageVector Unknown / missing names fall back to a generic star icon.
1 parent af75b2e commit a8a1c51

9 files changed

Lines changed: 552 additions & 19 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.github.damontecres.wholphin.data.model
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
enum class PagePosition {
8+
@SerialName("AfterHome")
9+
AFTER_HOME,
10+
11+
@SerialName("AfterFavorites")
12+
AFTER_FAVORITES,
13+
14+
@SerialName("AfterDiscover")
15+
AFTER_DISCOVER,
16+
17+
@SerialName("AfterLibraries")
18+
AFTER_LIBRARIES,
19+
20+
@SerialName("End")
21+
END,
22+
}
23+
24+
@Serializable
25+
data class PageSummary(
26+
val id: String,
27+
val title: String,
28+
val icon: String? = null,
29+
val position: PagePosition = PagePosition.AFTER_HOME,
30+
)
31+
32+
@Serializable
33+
data class PageConfig(
34+
val id: String,
35+
val title: String,
36+
val icon: String? = null,
37+
val position: PagePosition = PagePosition.AFTER_HOME,
38+
val rows: List<HomeRowConfig> = emptyList(),
39+
)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.github.damontecres.wholphin.services
2+
3+
import com.github.damontecres.wholphin.data.model.PageConfig
4+
import com.github.damontecres.wholphin.util.HomeRowLoadingState
5+
import org.jellyfin.sdk.model.UUID
6+
import java.util.concurrent.ConcurrentHashMap
7+
import javax.inject.Inject
8+
import javax.inject.Singleton
9+
10+
/**
11+
* Process-lifetime in-memory cache for custom-page rows.
12+
*
13+
* The Wholphin home page benefits from a long-lived state because its [Destination] sits at index 0
14+
* of the back stack and the HomeViewModel is never recreated. Custom pages get a fresh ViewModel on
15+
* every navigation (the back stack pops and re-pushes the entry), so without this cache every visit
16+
* re-fetches all rows. Keyed by userId to avoid leaking content across user switches.
17+
*/
18+
@Singleton
19+
class CustomPageRowsCache
20+
@Inject
21+
constructor() {
22+
private val cache = ConcurrentHashMap<String, CachedPageData>()
23+
24+
fun get(
25+
userId: UUID,
26+
pageId: String,
27+
): CachedPageData? = cache[key(userId, pageId)]
28+
29+
fun put(
30+
userId: UUID,
31+
pageId: String,
32+
data: CachedPageData,
33+
) {
34+
cache[key(userId, pageId)] = data
35+
}
36+
37+
fun clear() {
38+
cache.clear()
39+
}
40+
41+
private fun key(
42+
userId: UUID,
43+
pageId: String,
44+
) = "$userId:$pageId"
45+
}
46+
47+
data class CachedPageData(
48+
val page: PageConfig,
49+
val rows: List<HomeRowLoadingState>,
50+
)

app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import com.github.damontecres.wholphin.data.ServerPreferencesDao
66
import com.github.damontecres.wholphin.data.ServerRepository
77
import com.github.damontecres.wholphin.data.model.JellyfinUser
88
import com.github.damontecres.wholphin.data.model.NavPinType
9+
import com.github.damontecres.wholphin.data.model.PagePosition
910
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
1011
import com.github.damontecres.wholphin.ui.launchDefault
1112
import com.github.damontecres.wholphin.ui.main.settings.Library
13+
import com.github.damontecres.wholphin.ui.nav.CustomPageNavDrawerItem
1214
import com.github.damontecres.wholphin.ui.nav.Destination
1315
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
1416
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
@@ -52,6 +54,8 @@ class NavDrawerService
5254
private val serverPreferencesDao: ServerPreferencesDao,
5355
private val seerrServerRepository: SeerrServerRepository,
5456
private val musicService: MusicService,
57+
private val serverPluginApi: ServerPluginApi,
58+
private val customPageRowsCache: CustomPageRowsCache,
5559
) {
5660
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
5761
val state: StateFlow<NavDrawerItemState> = _state
@@ -70,6 +74,7 @@ class NavDrawerService
7074
moreItems = emptyList(),
7175
)
7276
}
77+
customPageRowsCache.clear()
7378
if (user != null && userDto != null && user.id == userDto.id) {
7479
updateNavDrawer(user, userDto)
7580
}
@@ -227,13 +232,92 @@ class NavDrawerService
227232
}
228233
}
229234

235+
val customPagesByPosition =
236+
fetchCustomPagesByPosition()
237+
238+
val itemsWithPages =
239+
insertCustomPages(items, customPagesByPosition)
240+
val moreItemsWithPages =
241+
moreItems +
242+
customPagesByPosition[PagePosition.END].orEmpty()
243+
230244
_state.update {
231245
it.copy(
232-
items = items,
233-
moreItems = moreItems,
246+
items = itemsWithPages,
247+
moreItems = moreItemsWithPages,
234248
)
235249
}
236250
}
251+
252+
private suspend fun fetchCustomPagesByPosition(): Map<PagePosition, List<CustomPageNavDrawerItem>> {
253+
val pages =
254+
try {
255+
serverPluginApi.fetchPages()
256+
} catch (ex: Exception) {
257+
Timber.w(ex, "Failed to fetch custom pages from plugin")
258+
return emptyMap()
259+
}
260+
return pages
261+
.map { CustomPageNavDrawerItem(it.id, it.title, it.icon) to it.position }
262+
.groupBy({ it.second }, { it.first })
263+
}
264+
265+
/**
266+
* Inserts custom pages into the items list at their configured positions:
267+
* - AfterHome → at the very start (Home itself is hardcoded in the composable, before [items])
268+
* - AfterFavorites → directly after the Favorites entry
269+
* - AfterDiscover → directly after the Discover entry
270+
* - AfterLibraries → after the last library entry
271+
*
272+
* If an anchor isn't present (e.g. user moved Discover to moreItems), the pages anchored on
273+
* it fall through to the end of the items list.
274+
*/
275+
private fun insertCustomPages(
276+
items: List<NavDrawerItem>,
277+
byPosition: Map<PagePosition, List<CustomPageNavDrawerItem>>,
278+
): List<NavDrawerItem> {
279+
if (byPosition.isEmpty()) return items
280+
281+
val result = mutableListOf<NavDrawerItem>()
282+
result += byPosition[PagePosition.AFTER_HOME].orEmpty()
283+
284+
var lastLibraryIndex = -1
285+
var sawFavorites = false
286+
var sawDiscover = false
287+
items.forEach { item ->
288+
result += item
289+
when (item) {
290+
NavDrawerItem.Favorites -> {
291+
result += byPosition[PagePosition.AFTER_FAVORITES].orEmpty()
292+
sawFavorites = true
293+
}
294+
295+
NavDrawerItem.Discover -> {
296+
result += byPosition[PagePosition.AFTER_DISCOVER].orEmpty()
297+
sawDiscover = true
298+
}
299+
300+
is ServerNavDrawerItem -> {
301+
lastLibraryIndex = result.size - 1
302+
}
303+
304+
else -> {}
305+
}
306+
}
307+
308+
val afterLibraries = byPosition[PagePosition.AFTER_LIBRARIES].orEmpty()
309+
if (afterLibraries.isNotEmpty()) {
310+
if (lastLibraryIndex >= 0) {
311+
result.addAll(lastLibraryIndex + 1, afterLibraries)
312+
} else {
313+
result += afterLibraries
314+
}
315+
}
316+
if (!sawFavorites) result += byPosition[PagePosition.AFTER_FAVORITES].orEmpty()
317+
if (!sawDiscover) result += byPosition[PagePosition.AFTER_DISCOVER].orEmpty()
318+
319+
return result
320+
}
237321
}
238322

239323
data class NavDrawerItemState(

app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.github.damontecres.wholphin.services
22

33
import com.github.damontecres.wholphin.data.model.HomePageSettings
4+
import com.github.damontecres.wholphin.data.model.PageConfig
5+
import com.github.damontecres.wholphin.data.model.PageSummary
46
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
57
import kotlinx.serialization.ExperimentalSerializationApi
68
import kotlinx.serialization.json.Json
@@ -25,11 +27,12 @@ class ServerPluginApi
2527

2628
private val json =
2729
Json {
28-
ignoreUnknownKeys = false
30+
ignoreUnknownKeys = true
2931
}
3032

3133
companion object {
3234
private const val HOME_CONFIG_PATH = "homesettings"
35+
private const val PAGES_PATH = "pages"
3336
}
3437

3538
suspend fun public(): Boolean {
@@ -63,4 +66,44 @@ class ServerPluginApi
6366
}
6467
}
6568
}
69+
70+
@OptIn(ExperimentalSerializationApi::class)
71+
suspend fun fetchPages(): List<PageSummary> {
72+
val url = createUrl(PAGES_PATH) ?: return emptyList()
73+
val request =
74+
Request
75+
.Builder()
76+
.url(url)
77+
.get()
78+
.build()
79+
return okHttpClient.newCall(request).execute().use { res ->
80+
if (res.isSuccessful) {
81+
json.decodeFromStream<List<PageSummary>>(res.body.byteStream())
82+
} else {
83+
Timber.w("fetchPages returned HTTP %d", res.code)
84+
emptyList()
85+
}
86+
}
87+
}
88+
89+
@OptIn(ExperimentalSerializationApi::class)
90+
suspend fun fetchPage(id: String): PageConfig? {
91+
val url = createUrl("$PAGES_PATH/$id") ?: return null
92+
val request =
93+
Request
94+
.Builder()
95+
.url(url)
96+
.get()
97+
.build()
98+
return okHttpClient.newCall(request).execute().use { res ->
99+
if (res.isSuccessful) {
100+
json.decodeFromStream<PageConfig>(res.body.byteStream())
101+
} else if (res.code == 404) {
102+
Timber.w("fetchPage(%s) returned 404", id)
103+
null
104+
} else {
105+
throw ApiClientException(res.code.toString() + " " + res.body.string())
106+
}
107+
}
108+
}
66109
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.github.damontecres.wholphin.ui.main
2+
3+
import androidx.compose.foundation.lazy.rememberLazyListState
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.runtime.LaunchedEffect
6+
import androidx.compose.runtime.collectAsState
7+
import androidx.compose.runtime.getValue
8+
import androidx.compose.runtime.setValue
9+
import androidx.compose.ui.Modifier
10+
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
11+
import com.github.damontecres.wholphin.preferences.UserPreferences
12+
import com.github.damontecres.wholphin.ui.components.ErrorMessage
13+
import com.github.damontecres.wholphin.ui.components.LoadingPage
14+
import com.github.damontecres.wholphin.ui.nav.Destination
15+
import com.github.damontecres.wholphin.ui.rememberPosition
16+
import com.github.damontecres.wholphin.util.LoadingState
17+
18+
@Composable
19+
fun CustomPagePage(
20+
pageId: String,
21+
title: String,
22+
preferences: UserPreferences,
23+
modifier: Modifier = Modifier,
24+
viewModel: CustomPageViewModel = hiltViewModel(),
25+
) {
26+
LaunchedEffect(pageId) { viewModel.load(pageId) }
27+
val state by viewModel.state.collectAsState()
28+
29+
when (val loading = state.loading) {
30+
is LoadingState.Error -> {
31+
ErrorMessage(loading, modifier)
32+
}
33+
34+
LoadingState.Loading,
35+
LoadingState.Pending,
36+
-> {
37+
LoadingPage(modifier)
38+
}
39+
40+
LoadingState.Success -> {
41+
var position by rememberPosition()
42+
val listState = rememberLazyListState()
43+
HomePageContent(
44+
homeRows = state.rows,
45+
position = position,
46+
onFocusPosition = { position = it },
47+
onClickItem = { _, item ->
48+
viewModel.navigationManager.navigateTo(item.destination())
49+
},
50+
onLongClickItem = { _, _ -> },
51+
onClickPlay = { _, item ->
52+
viewModel.navigationManager.navigateTo(Destination.Playback(item))
53+
},
54+
showClock = preferences.appPreferences.interfacePreferences.showClock,
55+
onUpdateBackdrop = viewModel::updateBackdrop,
56+
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
57+
showViewMore = false,
58+
modifier = modifier,
59+
loadingState = LoadingState.Success,
60+
listState = listState,
61+
)
62+
}
63+
}
64+
}

0 commit comments

Comments
 (0)