Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.github.damontecres.wholphin.data.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
enum class PagePosition {
@SerialName("AfterHome")
AFTER_HOME,

@SerialName("AfterFavorites")
AFTER_FAVORITES,

@SerialName("AfterDiscover")
AFTER_DISCOVER,

@SerialName("AfterLibraries")
AFTER_LIBRARIES,

@SerialName("End")
END,
}

@Serializable
data class PageSummary(
val id: String,
val title: String,
val icon: String? = null,
val position: PagePosition = PagePosition.AFTER_HOME,
)

@Serializable
data class PageConfig(
val id: String,
val title: String,
val icon: String? = null,
val position: PagePosition = PagePosition.AFTER_HOME,
val rows: List<HomeRowConfig> = emptyList(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.github.damontecres.wholphin.services

import com.github.damontecres.wholphin.data.model.PageConfig
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import org.jellyfin.sdk.model.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton

/**
* Process-lifetime in-memory cache for custom-page rows.
*
* The Wholphin home page benefits from a long-lived state because its [Destination] sits at index 0
* of the back stack and the HomeViewModel is never recreated. Custom pages get a fresh ViewModel on
* every navigation (the back stack pops and re-pushes the entry), so without this cache every visit
* re-fetches all rows. Keyed by userId to avoid leaking content across user switches.
*/
@Singleton
class CustomPageRowsCache
@Inject
constructor() {
private val cache = ConcurrentHashMap<String, CachedPageData>()

fun get(
userId: UUID,
pageId: String,
): CachedPageData? = cache[key(userId, pageId)]

fun put(
userId: UUID,
pageId: String,
data: CachedPageData,
) {
cache[key(userId, pageId)] = data
}

fun clear() {
cache.clear()
}

private fun key(
userId: UUID,
pageId: String,
) = "$userId:$pageId"
}

data class CachedPageData(
val page: PageConfig,
val rows: List<HomeRowLoadingState>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.data.model.PagePosition
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.CustomPageNavDrawerItem
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
Expand Down Expand Up @@ -52,6 +54,8 @@ class NavDrawerService
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
private val musicService: MusicService,
private val serverPluginApi: ServerPluginApi,
private val customPageRowsCache: CustomPageRowsCache,
) {
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
val state: StateFlow<NavDrawerItemState> = _state
Expand All @@ -70,6 +74,7 @@ class NavDrawerService
moreItems = emptyList(),
)
}
customPageRowsCache.clear()
if (user != null && userDto != null && user.id == userDto.id) {
updateNavDrawer(user, userDto)
}
Expand Down Expand Up @@ -227,13 +232,92 @@ class NavDrawerService
}
}

val customPagesByPosition =
fetchCustomPagesByPosition()

val itemsWithPages =
insertCustomPages(items, customPagesByPosition)
val moreItemsWithPages =
moreItems +
customPagesByPosition[PagePosition.END].orEmpty()

_state.update {
it.copy(
items = items,
moreItems = moreItems,
items = itemsWithPages,
moreItems = moreItemsWithPages,
)
}
}

private suspend fun fetchCustomPagesByPosition(): Map<PagePosition, List<CustomPageNavDrawerItem>> {
val pages =
try {
serverPluginApi.fetchPages()
} catch (ex: Exception) {
Timber.w(ex, "Failed to fetch custom pages from plugin")
return emptyMap()
}
return pages
.map { CustomPageNavDrawerItem(it.id, it.title, it.icon) to it.position }
.groupBy({ it.second }, { it.first })
}

/**
* Inserts custom pages into the items list at their configured positions:
* - AfterHome → at the very start (Home itself is hardcoded in the composable, before [items])
* - AfterFavorites → directly after the Favorites entry
* - AfterDiscover → directly after the Discover entry
* - AfterLibraries → after the last library entry
*
* If an anchor isn't present (e.g. user moved Discover to moreItems), the pages anchored on
* it fall through to the end of the items list.
*/
private fun insertCustomPages(
items: List<NavDrawerItem>,
byPosition: Map<PagePosition, List<CustomPageNavDrawerItem>>,
): List<NavDrawerItem> {
if (byPosition.isEmpty()) return items

val result = mutableListOf<NavDrawerItem>()
result += byPosition[PagePosition.AFTER_HOME].orEmpty()

var lastLibraryIndex = -1
var sawFavorites = false
var sawDiscover = false
items.forEach { item ->
result += item
when (item) {
NavDrawerItem.Favorites -> {
result += byPosition[PagePosition.AFTER_FAVORITES].orEmpty()
sawFavorites = true
}

NavDrawerItem.Discover -> {
result += byPosition[PagePosition.AFTER_DISCOVER].orEmpty()
sawDiscover = true
}

is ServerNavDrawerItem -> {
lastLibraryIndex = result.size - 1
}

else -> {}
}
}

val afterLibraries = byPosition[PagePosition.AFTER_LIBRARIES].orEmpty()
if (afterLibraries.isNotEmpty()) {
if (lastLibraryIndex >= 0) {
result.addAll(lastLibraryIndex + 1, afterLibraries)
} else {
result += afterLibraries
}
}
if (!sawFavorites) result += byPosition[PagePosition.AFTER_FAVORITES].orEmpty()
if (!sawDiscover) result += byPosition[PagePosition.AFTER_DISCOVER].orEmpty()

return result
}
}

data class NavDrawerItemState(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.services

import com.github.damontecres.wholphin.data.model.HomePageSettings
import com.github.damontecres.wholphin.data.model.PageConfig
import com.github.damontecres.wholphin.data.model.PageSummary
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
Expand All @@ -25,11 +27,12 @@ class ServerPluginApi

private val json =
Json {
ignoreUnknownKeys = false
ignoreUnknownKeys = true
}

companion object {
private const val HOME_CONFIG_PATH = "homesettings"
private const val PAGES_PATH = "pages"
}

suspend fun public(): Boolean {
Expand Down Expand Up @@ -63,4 +66,44 @@ class ServerPluginApi
}
}
}

@OptIn(ExperimentalSerializationApi::class)
suspend fun fetchPages(): List<PageSummary> {
val url = createUrl(PAGES_PATH) ?: return emptyList()
val request =
Request
.Builder()
.url(url)
.get()
.build()
return okHttpClient.newCall(request).execute().use { res ->
if (res.isSuccessful) {
json.decodeFromStream<List<PageSummary>>(res.body.byteStream())
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrap the list in an object. This makes it easier to add new fields in the future that older versions of the client can ignore

} else {
Timber.w("fetchPages returned HTTP %d", res.code)
emptyList()
}
}
}

@OptIn(ExperimentalSerializationApi::class)
suspend fun fetchPage(id: String): PageConfig? {
val url = createUrl("$PAGES_PATH/$id") ?: return null
val request =
Request
.Builder()
.url(url)
.get()
.build()
return okHttpClient.newCall(request).execute().use { res ->
if (res.isSuccessful) {
json.decodeFromStream<PageConfig>(res.body.byteStream())
} else if (res.code == 404) {
Timber.w("fetchPage(%s) returned 404", id)
null
} else {
throw ApiClientException(res.code.toString() + " " + res.body.string())
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use InvalidStatusException instead

}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.github.damontecres.wholphin.ui.main

import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.util.LoadingState

@Composable
fun CustomPagePage(
pageId: String,
title: String,
preferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: CustomPageViewModel = hiltViewModel(),
) {
LaunchedEffect(pageId) { viewModel.load(pageId) }
val state by viewModel.state.collectAsState()

when (val loading = state.loading) {
is LoadingState.Error -> {
ErrorMessage(loading, modifier)
}

LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage(modifier)
}

LoadingState.Success -> {
var position by rememberPosition()
val listState = rememberLazyListState()
HomePageContent(
homeRows = state.rows,
position = position,
onFocusPosition = { position = it },
onClickItem = { _, item ->
viewModel.navigationManager.navigateTo(item.destination())
},
onLongClickItem = { _, _ -> },
onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item))
},
showClock = preferences.appPreferences.interfacePreferences.showClock,
onUpdateBackdrop = viewModel::updateBackdrop,
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
showViewMore = false,
modifier = modifier,
loadingState = LoadingState.Success,
listState = listState,
)
}
}
}
Loading