From 400218aac7f3c0d44ddb643d5bed2241871e505c Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Fri, 2 Jan 2026 13:44:15 +0100 Subject: [PATCH 01/18] Add WholphinPluginService for Jellyfin plugin API interaction --- .../services/WholphinPluginService.kt | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt new file mode 100644 index 000000000..bb3797a68 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt @@ -0,0 +1,232 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Service to interact with Wholphin plugin API endpoints on the Jellyfin server + * + * Provides both authenticated and unauthenticated access to plugin endpoints: + * - Use [getLoginBackground] for unauthenticated endpoints (like login screen) + * - Use [makeAuthenticatedRequest] for endpoints that require user authentication + */ +@Singleton +class WholphinPluginService + @Inject + constructor( + @param:StandardOkHttpClient private val standardOkHttpClient: OkHttpClient, + @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, + ) { + companion object { + private const val PLUGIN_BASE_PATH = "/wholphin" + private const val CAPABILITIES_ENDPOINT = "$PLUGIN_BASE_PATH/capabilities" + private const val LOGIN_BACKGROUND_ENDPOINT = "$PLUGIN_BASE_PATH/loginbackground" + } + + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Check which features/capabilities the Wholphin plugin supports + * + * This should be called first to determine what endpoints are available. + * The plugin should implement a /wholphin/capabilities endpoint that returns + * a JSON object describing supported features. + * + * @param serverUrl The base URL of the Jellyfin server + * @return PluginCapabilities object describing available features, or null if plugin is not available + */ + suspend fun getPluginCapabilities(serverUrl: String): PluginCapabilities? = + try { + val normalizedUrl = serverUrl.trimEnd('/') + val endpoint = "$normalizedUrl$CAPABILITIES_ENDPOINT" + + val request = + Request + .Builder() + .url(endpoint) + .get() + .build() + + val response = standardOkHttpClient.newCall(request).execute() + + if (response.isSuccessful) { + val body = response.body?.string() + if (body != null) { + try { + val capabilities = json.decodeFromString(body) + Timber.i("Wholphin plugin capabilities on $serverUrl: $capabilities") + capabilities + } catch (e: Exception) { + Timber.w(e, "Failed to parse plugin capabilities response") + null + } + } else { + Timber.w("Plugin capabilities endpoint returned empty body") + null + } + } else { + Timber.v("Wholphin plugin capabilities not available on $serverUrl (status: ${response.code})") + null + } + } catch (e: Exception) { + Timber.v(e, "Error checking for Wholphin plugin capabilities on $serverUrl") + null + } + + /** + * Check if the Wholphin plugin is available on the server and fetch the login background URL + * + * This endpoint allows anonymous access since it's used on the login screen. + * + * @param serverUrl The base URL of the Jellyfin server + * @return LoginBackgroundResult containing the background image URL if available, or null if the plugin is not available + */ + suspend fun getLoginBackground(serverUrl: String): LoginBackgroundResult = + try { + val normalizedUrl = serverUrl.trimEnd('/') + val endpoint = "$normalizedUrl$LOGIN_BACKGROUND_ENDPOINT" + + val request = + Request + .Builder() + .url(endpoint) + .get() + .build() + + val response = standardOkHttpClient.newCall(request).execute() + + if (response.isSuccessful) { + val body = response.body?.string() + if (body != null) { + try { + val backgroundData = json.decodeFromString(body) + if (backgroundData.backgroundUrl.isNotBlank()) { + Timber.i( + "Wholphin plugin found on $serverUrl, background URL: ${backgroundData.backgroundUrl}, alpha: ${backgroundData.alpha}, blur: ${backgroundData.blur}" + ) + LoginBackgroundResult.Available( + backgroundUrl = backgroundData.backgroundUrl, + alpha = backgroundData.alpha, + blur = backgroundData.blur, + ) + } else { + Timber.w("Wholphin plugin returned empty background URL") + LoginBackgroundResult.NotAvailable + } + } catch (e: Exception) { + Timber.w(e, "Failed to parse login background response") + LoginBackgroundResult.NotAvailable + } + } else { + Timber.w("Wholphin plugin returned empty body") + LoginBackgroundResult.NotAvailable + } + } else { + Timber.v("Wholphin plugin not available on $serverUrl (status: ${response.code})") + LoginBackgroundResult.NotAvailable + } + } catch (e: Exception) { + Timber.v(e, "Error checking for Wholphin plugin on $serverUrl") + LoginBackgroundResult.NotAvailable + } + + /** + * Make an authenticated request to a Wholphin plugin endpoint + * + * This uses the authenticated HTTP client which includes the current user's access token. + * Use this for plugin endpoints that require user authentication. + * + * @param serverUrl The base URL of the Jellyfin server + * @param endpoint The plugin endpoint path (e.g., "/wholphin/myendpoint") + * @param requestBuilder Optional function to customize the request (add body, method, headers, etc.) + * @return The HTTP response if successful, null otherwise + */ + suspend fun makeAuthenticatedRequest( + serverUrl: String, + endpoint: String, + requestBuilder: (Request.Builder) -> Request.Builder = { it }, + ): okhttp3.Response? = + try { + val normalizedUrl = serverUrl.trimEnd('/') + val fullEndpoint = "$normalizedUrl$endpoint" + + val baseRequest = + Request + .Builder() + .url(fullEndpoint) + .get() + + val request = requestBuilder(baseRequest).build() + val response = authOkHttpClient.newCall(request).execute() + + if (response.isSuccessful) { + response + } else { + Timber.w("Authenticated request to $endpoint failed with status: ${response.code}") + response.close() + null + } + } catch (e: Exception) { + Timber.e(e, "Error making authenticated request to $endpoint") + null + } + } + +@Serializable +data class PluginCapabilities( + val version: String = "1.0.0", + val features: Features = Features(), +) { + @Serializable + data class Features( + val loginBackground: Boolean = false, + // Add more features here as the plugin grows + // val customThemes: Boolean = false, + // val enhancedMetadata: Boolean = false, + // val socialFeatures: Boolean = false, + ) + + /** + * Check if a specific feature is supported + */ + fun hasFeature(feature: PluginFeature): Boolean = + when (feature) { + PluginFeature.LOGIN_BACKGROUND -> features.loginBackground + } +} + +/** + * Enum of all possible plugin features for type-safe feature checking + */ +enum class PluginFeature { + LOGIN_BACKGROUND, + // Add more features here as needed +} + +@Serializable +data class LoginBackgroundData( + val backgroundUrl: String = "", + val alpha: Float = 0.2f, + val blur: Int = 1, +) + +sealed class LoginBackgroundResult { + data class Available( + val backgroundUrl: String, + val alpha: Float = 0.2f, + val blur: Int = 1, + ) : LoginBackgroundResult() + + object NotAvailable : LoginBackgroundResult() +} From dfbfc6268d02a39590e1cdb910d6db696b6279f6 Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Fri, 2 Jan 2026 13:44:37 +0100 Subject: [PATCH 02/18] Implement Wholphin plugin background login support in SwitchUserViewModel and SwitchServerViewModel --- .../ui/setup/SwitchServerViewModel.kt | 24 ++++++++++++++++ .../wholphin/ui/setup/SwitchUserContent.kt | 28 +++++++++++++++++-- .../wholphin/ui/setup/SwitchUserViewModel.kt | 17 +++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index f9e7d0a3d..a34c1d074 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -9,8 +9,11 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.services.LoginBackgroundResult +import com.github.damontecres.wholphin.services.PluginFeature import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.services.WholphinPluginService import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast @@ -42,10 +45,12 @@ class SwitchServerViewModel val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, val navigationManager: SetupNavigationManager, + val wholphinPluginService: WholphinPluginService, ) : ViewModel() { val servers = MutableLiveData>(listOf()) val serverStatus = MutableLiveData>(mapOf()) val serverQuickConnect = MutableLiveData>(mapOf()) + val serverLoginBackground = MutableLiveData>(mapOf()) val discoveredServers = MutableLiveData>(listOf()) @@ -60,6 +65,7 @@ class SwitchServerViewModel withContext(Dispatchers.Main) { serverStatus.value = mapOf() serverQuickConnect.value = mapOf() + serverLoginBackground.value = mapOf() } val allServers = @@ -120,6 +126,8 @@ class SwitchServerViewModel ) } } + // Check for Wholphin plugin background + checkPluginBackground(server) result } catch (ex: Exception) { val status = ServerConnectionStatus.Error(ex.localizedMessage) @@ -276,4 +284,20 @@ class SwitchServerViewModel } } } + + private suspend fun checkPluginBackground(server: JellyfinServer) { + // First check if plugin is available and supports login background + val capabilities = wholphinPluginService.getPluginCapabilities(server.url) + if (capabilities?.hasFeature(PluginFeature.LOGIN_BACKGROUND) == true) { + val backgroundResult = wholphinPluginService.getLoginBackground(server.url) + if (backgroundResult is LoginBackgroundResult.Available) { + withContext(Dispatchers.Main) { + serverLoginBackground.value = + serverLoginBackground.value!!.toMutableMap().apply { + put(server.id, backgroundResult) + } + } + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index b71e0af00..aa54ec002 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -21,8 +22,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -34,6 +38,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -47,6 +52,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import timber.log.Timber @Composable fun SwitchUserContent( @@ -69,6 +75,7 @@ fun SwitchUserContent( val quickConnectEnabled by viewModel.serverQuickConnect.observeAsState(false) val quickConnect by viewModel.quickConnectState.observeAsState(null) var showAddUser by remember { mutableStateOf(false) } + val loginBackground by viewModel.loginBackground.observeAsState(null) val userState by viewModel.switchUserState.observeAsState(LoadingState.Pending) val loginAttempts by viewModel.loginAttempts.observeAsState(0) @@ -88,8 +95,23 @@ fun SwitchUserContent( currentServer?.let { server -> Box( - modifier = modifier.dimAndBlur(showAddUser || switchUserWithPin != null), + modifier = modifier.fillMaxSize(), ) { + // Background image for the entire Select User screen + loginBackground?.let { background -> + AsyncImage( + model = background.backgroundUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxSize() + .alpha(background.alpha) + .blur(background.blur.dp), + ) + } + + // Content layer Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), @@ -97,7 +119,8 @@ fun SwitchUserContent( Modifier .fillMaxWidth() .align(Alignment.Center) - .padding(16.dp), + .padding(16.dp) + .dimAndBlur(showAddUser || switchUserWithPin != null), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -149,6 +172,7 @@ fun SwitchUserContent( viewModel.initiateQuickConnect(server) } } + BasicDialog( onDismissRequest = { viewModel.cancelQuickConnect() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index 68b9e583e..b626380bd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -8,9 +8,12 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.LoginBackgroundResult import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.PluginFeature import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.services.WholphinPluginService import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler @@ -47,6 +50,7 @@ class SwitchUserViewModel val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val imageUrlService: ImageUrlService, + val wholphinPluginService: WholphinPluginService, @Assisted val server: JellyfinServer, ) : ViewModel() { @AssistedFactory @@ -58,6 +62,8 @@ class SwitchUserViewModel val users = MutableLiveData>(listOf()) val quickConnectState = MutableLiveData(null) + + val loginBackground = MutableLiveData(null) private var quickConnectJob: Job? = null @@ -88,6 +94,17 @@ class SwitchUserViewModel withContext(Dispatchers.Main) { users.setValueOnMain(serverUsers) } + + // Check for Wholphin plugin login background + val capabilities = wholphinPluginService.getPluginCapabilities(server.url) + if (capabilities?.hasFeature(PluginFeature.LOGIN_BACKGROUND) == true) { + val backgroundResult = wholphinPluginService.getLoginBackground(server.url) + if (backgroundResult is LoginBackgroundResult.Available) { + withContext(Dispatchers.Main) { + loginBackground.value = backgroundResult + } + } + } } viewModelScope.launchIO { From 9696b81465167689f3b4b362c2c20536ff0de4e5 Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Fri, 2 Jan 2026 18:05:33 +0100 Subject: [PATCH 03/18] Add home configuration endpoint and data classes to WholphinPluginService --- .../services/WholphinPluginService.kt | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt index bb3797a68..6352bc084 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt @@ -2,7 +2,14 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import okhttp3.Request @@ -28,6 +35,7 @@ class WholphinPluginService private const val PLUGIN_BASE_PATH = "/wholphin" private const val CAPABILITIES_ENDPOINT = "$PLUGIN_BASE_PATH/capabilities" private const val LOGIN_BACKGROUND_ENDPOINT = "$PLUGIN_BASE_PATH/loginbackground" + private const val HOME_ENDPOINT = "$PLUGIN_BASE_PATH/home" } private val json = @@ -141,6 +149,57 @@ class WholphinPluginService LoginBackgroundResult.NotAvailable } + /** + * Get home page configuration from the Wholphin plugin + * + * This endpoint requires authentication and returns the dynamic home screen configuration. + * The configuration defines which sections (rows) to display on the home page and how to fetch their data. + * + * @param serverUrl The base URL of the Jellyfin server + * @return HomeConfiguration object with sections, or null if not available or on error + */ + suspend fun getHomeConfiguration(serverUrl: String): HomeConfiguration? = + try { + val normalizedUrl = serverUrl.trimEnd('/') + val endpoint = "$normalizedUrl$HOME_ENDPOINT" + + val request = + Request + .Builder() + .url(endpoint) + .get() + .build() + + val response = authOkHttpClient.newCall(request).execute() + + if (response.isSuccessful) { + val body = response.body?.string() + if (body != null) { + try { + val config = json.decodeFromString(body) + Timber.i( + "Loaded home configuration from $serverUrl: ${config.sections.size} sections" + ) + config + } catch (e: Exception) { + Timber.w(e, "Failed to parse home configuration response") + null + } + } else { + Timber.w("Home configuration endpoint returned empty body") + null + } + } else { + Timber.v( + "Home configuration not available on $serverUrl (status: ${response.code})" + ) + null + } + } catch (e: Exception) { + Timber.v(e, "Error fetching home configuration from $serverUrl") + null + } + /** * Make an authenticated request to a Wholphin plugin endpoint * @@ -191,6 +250,7 @@ data class PluginCapabilities( @Serializable data class Features( val loginBackground: Boolean = false, + val homeConfiguration: Boolean = false, // Add more features here as the plugin grows // val customThemes: Boolean = false, // val enhancedMetadata: Boolean = false, @@ -203,6 +263,7 @@ data class PluginCapabilities( fun hasFeature(feature: PluginFeature): Boolean = when (feature) { PluginFeature.LOGIN_BACKGROUND -> features.loginBackground + PluginFeature.HOME_CONFIGURATION -> features.homeConfiguration } } @@ -211,6 +272,7 @@ data class PluginCapabilities( */ enum class PluginFeature { LOGIN_BACKGROUND, + HOME_CONFIGURATION, // Add more features here as needed } @@ -230,3 +292,144 @@ sealed class LoginBackgroundResult { object NotAvailable : LoginBackgroundResult() } + +// ============================================================================ +// Home Configuration Data Classes +// ============================================================================ + +/** + * Complete home page configuration from the Wholphin plugin + * + * The plugin version is checked via the capabilities endpoint. + * This configuration is version-agnostic - unknown section types are simply ignored by the client. + * + * Example JSON from server: + * ```json + * { + * "sections": [ + * { + * "id": "continue-watching", + * "title": "Continue Watching", + * "type": "resume", + * "limit": 20 + * }, + * { + * "id": "trending", + * "title": "Trending Now", + * "type": "custom", + * "endpoint": "/wholphin/trending", + * "limit": 15 + * } + * ] + * } + * ``` + */ +@Serializable +data class HomeConfiguration( + val sections: List = emptyList(), +) + +/** + * A single section (row) in the home page + * + * Each section can be of different types: + * - RESUME: Continue watching items (GetResumeItems API) + * - NEXT_UP: Next episodes to watch (GetNextUp API) + * - LATEST: Recently added items (GetLatestMedia API) + * - ITEMS: Custom query using GetItems API with filters + * - CUSTOM: Custom endpoint defined by the plugin + * + * @param id Unique identifier for this section + * @param title Display title for the row (e.g., "Continue Watching", "Trending") + * @param type Type of section determining which API to use + * @param limit Maximum number of items to display (default: 20) + * @param query Optional query parameters for ITEMS and LATEST types + * @param endpoint Optional custom endpoint path for CUSTOM type (e.g., "/wholphin/trending") + */ +@Serializable +data class HomeSection( + val id: String, + val title: String, + val type: HomeSectionType, + val limit: Int = 20, + val query: HomeSectionQuery? = null, + val endpoint: String? = null, +) + +/** + * Type of home section determining which Jellyfin API to use + * + * Note: C# server sends these as integer values (0-4), not strings. + * The order must match the C# enum exactly. + */ +@Serializable(with = HomeSectionTypeSerializer::class) +enum class HomeSectionType { + /** Continue watching - uses GetResumeItems API */ + RESUME, // 0 + + /** Next episodes to watch - uses GetNextUp API */ + NEXT_UP, // 1 + + /** Recently added items - uses GetLatestMedia API */ + LATEST, // 2 + + /** Custom query - uses GetItems API with filters */ + ITEMS, // 3 + + /** Custom plugin endpoint - calls the specified endpoint */ + CUSTOM, // 4 +} + +/** + * Custom serializer for HomeSectionType that handles both integer and string values from C# server + */ +object HomeSectionTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("HomeSectionType", PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: HomeSectionType) { + encoder.encodeInt(value.ordinal) + } + + override fun deserialize(decoder: Decoder): HomeSectionType { + val index = decoder.decodeInt() + return HomeSectionType.entries.getOrNull(index) + ?: throw IllegalArgumentException("Unknown HomeSectionType ordinal: $index") + } +} + +/** + * Query parameters for ITEMS and LATEST section types + * + * These map to Jellyfin API parameters: + * - parentId: Limit to specific library or collection + * - filters: Item filters (e.g., "IsUnplayed", "IsFavorite") + * - includeItemTypes: Item types to include (e.g., "Movie", "Series", "Episode") + * - sortBy: Sort field (e.g., "DateCreated", "SortName", "CommunityRating") + * - sortOrder: "Ascending" or "Descending" + * - genres: Filter by genre names + * - enableRewatching: For NEXT_UP type, allow already watched episodes + * - enableResumable: For NEXT_UP type, include partially watched episodes + * + * Example: + * ```json + * { + * "parentId": "abc123", + * "filters": ["IsUnplayed"], + * "includeItemTypes": ["Movie"], + * "sortBy": ["DateCreated"], + * "limit": 25 + * } + * ``` + */ +@Serializable +data class HomeSectionQuery( + val parentId: String? = null, + val filters: List? = null, + val includeItemTypes: List? = null, + val sortBy: List? = null, + val sortOrder: String? = null, + val genres: List? = null, + val enableRewatching: Boolean? = null, + val enableResumable: Boolean? = null, +) From 7c08275038339c1329eee6e30aa8d394988de99c Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Fri, 2 Jan 2026 18:05:45 +0100 Subject: [PATCH 04/18] Implement plugin-driven home configuration loading in HomeViewModel --- .../wholphin/ui/main/HomeViewModel.kt | 404 +++++++++++++++--- 1 file changed, 352 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index e2704ad8d..0727e4c65 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.WholphinPluginService import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.setValueOnMain @@ -27,8 +28,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import timber.log.Timber import java.util.UUID @@ -47,13 +52,22 @@ class HomeViewModel private val datePlayedService: DatePlayedService, private val latestNextUpService: LatestNextUpService, private val backdropService: BackdropService, + private val wholphinPluginService: WholphinPluginService, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) val refreshState = MutableLiveData(LoadingState.Pending) + + // Keep separate for backward compatibility with existing UI val watchingRows = MutableLiveData>(listOf()) val latestRows = MutableLiveData>(listOf()) private lateinit var preferences: UserPreferences + private var usingPluginConfiguration = false + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } init { datePlayedService.invalidateAll() @@ -68,6 +82,7 @@ class HomeViewModel this.preferences = preferences val prefs = preferences.appPreferences.homePagePreferences val limit = prefs.maxItemsPerRow + return viewModelScope.launch( Dispatchers.IO + LoadingExceptionHandler( @@ -81,64 +96,349 @@ class HomeViewModel } serverRepository.currentUserDto.value?.let { userDto -> - val includedIds = - navDrawerItemRepository - .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) - .filter { it is ServerNavDrawerItem } - .map { (it as ServerNavDrawerItem).itemId } - val resume = latestNextUpService.getResume(userDto.id, limit, true) - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - ) - val watching = - buildList { - if (prefs.combineContinueNext) { - val items = latestNextUpService.buildCombined(resume, nextUp) - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.continue_watching), - items = items, - ), - ) - } else { - if (resume.isNotEmpty()) { - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.continue_watching), - items = resume, - ), - ) - } - if (nextUp.isNotEmpty()) { - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.next_up), - items = nextUp, - ), - ) - } - } - } + // Try to load plugin configuration first + val serverUrl = serverRepository.currentServer.value?.url + val pluginConfig = if (serverUrl != null) { + wholphinPluginService.getHomeConfiguration(serverUrl) + } else { + null + } + + if (pluginConfig != null && pluginConfig.sections.isNotEmpty()) { + // Use plugin-driven configuration + Timber.i("Using Wholphin plugin home configuration with ${pluginConfig.sections.size} sections") + usingPluginConfiguration = true + loadPluginSections(pluginConfig, userDto.id, limit) + } else { + // Fallback to default behavior + Timber.d("Using default home configuration") + usingPluginConfiguration = false + loadDefaultSections(userDto, prefs, limit, reload) + } + } + } + } + + /** + * Load home sections from plugin configuration + */ + private suspend fun loadPluginSections( + config: com.github.damontecres.wholphin.services.HomeConfiguration, + userId: UUID, + defaultLimit: Int, + ) { + // Show all sections as loading first + val pendingRows = config.sections.map { + HomeRowLoadingState.Loading(it.title) + } + + withContext(Dispatchers.Main) { + watchingRows.value = emptyList() + latestRows.value = pendingRows + loadingState.value = LoadingState.Success + } + + refreshState.setValueOnMain(LoadingState.Success) + + // Load each section based on its type + val loadedRows = config.sections.map { section -> + try { + val items = when (section.type) { + com.github.damontecres.wholphin.services.HomeSectionType.RESUME -> + loadResumeSection(userId, section.limit) + + com.github.damontecres.wholphin.services.HomeSectionType.NEXT_UP -> + loadNextUpSection(userId, section) + + com.github.damontecres.wholphin.services.HomeSectionType.LATEST -> + loadLatestSection(userId, section) + + com.github.damontecres.wholphin.services.HomeSectionType.ITEMS -> + loadItemsSection(userId, section) + + com.github.damontecres.wholphin.services.HomeSectionType.CUSTOM -> + loadCustomSection(section) + } + + if (items.isNotEmpty()) { + HomeRowLoadingState.Success(section.title, items) + } else { + null // Skip empty sections + } + } catch (e: Exception) { + Timber.e(e, "Error loading section ${section.id} (${section.type})") + HomeRowLoadingState.Error( + title = section.title, + exception = e + ) + } + }.filterNotNull() + + // Update UI with loaded sections + latestRows.setValueOnMain(loadedRows) + } + + /** + * Load RESUME section (Continue Watching) + */ + private suspend fun loadResumeSection(userId: UUID, limit: Int): List { + return latestNextUpService.getResume(userId, limit, includeEpisodes = true) + } + + /** + * Load NEXT_UP section + */ + private suspend fun loadNextUpSection( + userId: UUID, + section: com.github.damontecres.wholphin.services.HomeSection, + ): List { + val enableRewatching = section.query?.enableRewatching ?: false + val enableResumable = section.query?.enableResumable ?: false + return latestNextUpService.getNextUp( + userId, + section.limit, + enableRewatching, + enableResumable + ) + } + + /** + * Load LATEST section (Recently Added) + */ + private suspend fun loadLatestSection( + userId: UUID, + section: com.github.damontecres.wholphin.services.HomeSection, + ): List { + val parentId = section.query?.parentId?.let { parseUUID(it) } + + if (parentId != null) { + // Single library + val request = org.jellyfin.sdk.model.api.request.GetLatestMediaRequest( + fields = com.github.damontecres.wholphin.ui.SlimItemFields, + imageTypeLimit = 1, + parentId = parentId, + groupItems = true, + limit = section.limit, + isPlayed = null, + ) + + return api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem.from(it, api, true) } + } else { + // All libraries - use existing logic + val user = serverRepository.currentUserDto.value ?: return emptyList() + val includedIds = navDrawerItemRepository + .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) + .filter { it is ServerNavDrawerItem } + .map { (it as ServerNavDrawerItem).itemId } + + val latestData = latestNextUpService.getLatest(user, section.limit, includedIds) + val rows = latestNextUpService.loadLatest(latestData) + + // Flatten all items from all libraries + return rows.filterIsInstance() + .flatMap { it.items } + .filterNotNull() + .take(section.limit) + } + } + + /** + * Load ITEMS section (Custom Query) + */ + private suspend fun loadItemsSection( + userId: UUID, + section: com.github.damontecres.wholphin.services.HomeSection, + ): List { + val query = section.query ?: return emptyList() + + // Parse filters + val filters = query.filters?.mapNotNull { filterStr -> + try { + org.jellyfin.sdk.model.api.ItemFilter.valueOf(filterStr) + } catch (e: Exception) { + Timber.w("Unknown filter: $filterStr") + null + } + } + + // Parse item types + val itemTypes = query.includeItemTypes?.mapNotNull { typeStr -> + try { + org.jellyfin.sdk.model.api.BaseItemKind.valueOf(typeStr.uppercase()) + } catch (e: Exception) { + Timber.w("Unknown item type: $typeStr") + null + } + } + + // Parse sort by + val sortBy = query.sortBy?.mapNotNull { sortStr -> + ItemSortBy.fromNameOrNull(sortStr).also { sortBy -> + if (sortBy == null) { + Timber.w("Unknown sort by: $sortStr") + } + } + } + + // Parse sort order + val sortOrder = query.sortOrder?.let { orderStr -> + try { + listOf(org.jellyfin.sdk.model.api.SortOrder.valueOf(orderStr.uppercase())) + } catch (e: Exception) { + Timber.w("Unknown sort order: $orderStr") + null + } + } + + // Build request + val request = org.jellyfin.sdk.model.api.request.GetItemsRequest( + userId = userId, + parentId = query.parentId?.let { parseUUID(it) }, + filters = filters, + includeItemTypes = itemTypes, + sortBy = sortBy, + sortOrder = sortOrder, + limit = section.limit, + fields = com.github.damontecres.wholphin.ui.SlimItemFields, + enableUserData = true, + recursive = true, + ) + + return api.itemsApi + .getItems(request) + .content + .items + .map { BaseItem.from(it, api, true) } + } + + /** + * Parse UUID from string, supporting both formats with and without dashes + * Jellyfin GUIDs come without dashes from C# (e.g., "39e84a9304c28059a7c197d5d9a5edbe") + */ + private fun parseUUID(uuidString: String): UUID { + return if (uuidString.contains("-")) { + // Already has dashes + UUID.fromString(uuidString) + } else if (uuidString.length == 32) { + // No dashes, add them: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + val formatted = buildString { + append(uuidString.substring(0, 8)) + append('-') + append(uuidString.substring(8, 12)) + append('-') + append(uuidString.substring(12, 16)) + append('-') + append(uuidString.substring(16, 20)) + append('-') + append(uuidString.substring(20, 32)) + } + UUID.fromString(formatted) + } else { + throw IllegalArgumentException("Invalid UUID string: $uuidString") + } + } - val latest = latestNextUpService.getLatest(userDto, limit, includedIds) - val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } + /** + * Load CUSTOM section (Plugin Endpoint) + */ + private suspend fun loadCustomSection( + section: com.github.damontecres.wholphin.services.HomeSection, + ): List { + val endpoint = section.endpoint ?: run { + Timber.w("Custom section ${section.id} has no endpoint") + return emptyList() + } + + val serverUrl = serverRepository.currentServer.value?.url ?: return emptyList() + + val response = wholphinPluginService.makeAuthenticatedRequest(serverUrl, endpoint) + + if (response != null) { + try { + val body = response.body?.string() + if (body != null) { + // Expect JSON array of BaseItemDto + val items = json.decodeFromString>(body) + return items.map { BaseItem.from(it, api, true) } + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse custom endpoint response for ${section.id}") + } finally { + response.close() + } + } + + return emptyList() + } - withContext(Dispatchers.Main) { - this@HomeViewModel.watchingRows.value = watching - if (reload) { - this@HomeViewModel.latestRows.value = pendingLatest + /** + * Load home sections using default behavior (existing logic) + */ + private suspend fun loadDefaultSections( + userDto: org.jellyfin.sdk.model.api.UserDto, + prefs: com.github.damontecres.wholphin.preferences.HomePagePreferences, + limit: Int, + reload: Boolean, + ) { + val includedIds = + navDrawerItemRepository + .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) + .filter { it is ServerNavDrawerItem } + .map { (it as ServerNavDrawerItem).itemId } + val resume = latestNextUpService.getResume(userDto.id, limit, true) + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + ) + val watching = + buildList { + if (prefs.combineContinueNext) { + val items = latestNextUpService.buildCombined(resume, nextUp) + add( + HomeRowLoadingState.Success( + title = context.getString(R.string.continue_watching), + items = items, + ), + ) + } else { + if (resume.isNotEmpty()) { + add( + HomeRowLoadingState.Success( + title = context.getString(R.string.continue_watching), + items = resume, + ), + ) + } + if (nextUp.isNotEmpty()) { + add( + HomeRowLoadingState.Success( + title = context.getString(R.string.next_up), + items = nextUp, + ), + ) } - loadingState.value = LoadingState.Success } - refreshState.setValueOnMain(LoadingState.Success) - val loadedLatest = latestNextUpService.loadLatest(latest) - this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) } + + val latest = latestNextUpService.getLatest(userDto, limit, includedIds) + val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } + + withContext(Dispatchers.Main) { + this@HomeViewModel.watchingRows.value = watching + if (reload) { + this@HomeViewModel.latestRows.value = pendingLatest + } + loadingState.value = LoadingState.Success } + refreshState.setValueOnMain(LoadingState.Success) + val loadedLatest = latestNextUpService.loadLatest(latest) + this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) } fun setWatched( From 76a1fb89f70055903ae835a38a7280a284a2828f Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Sat, 3 Jan 2026 16:17:57 +0100 Subject: [PATCH 05/18] Add plugin settings retrieval to WholphinPluginService --- .../services/WholphinPluginService.kt | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt index 6352bc084..23ac404f6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt @@ -36,6 +36,7 @@ class WholphinPluginService private const val CAPABILITIES_ENDPOINT = "$PLUGIN_BASE_PATH/capabilities" private const val LOGIN_BACKGROUND_ENDPOINT = "$PLUGIN_BASE_PATH/loginbackground" private const val HOME_ENDPOINT = "$PLUGIN_BASE_PATH/home" + private const val SETTINGS_ENDPOINT = "$PLUGIN_BASE_PATH/settings" } private val json = @@ -200,6 +201,53 @@ class WholphinPluginService null } + /** + * Get plugin settings from the Wholphin plugin + * + * This endpoint requires authentication and returns general plugin settings. + * Currently includes the Seerr URL for media requests integration. + * + * @param serverUrl The base URL of the Jellyfin server + * @return PluginSettings object with settings, or null if not available or on error + */ + suspend fun getPluginSettings(serverUrl: String): PluginSettings? = + try { + val normalizedUrl = serverUrl.trimEnd('/') + val endpoint = "$normalizedUrl$SETTINGS_ENDPOINT" + + val request = + Request + .Builder() + .url(endpoint) + .get() + .build() + + val response = authOkHttpClient.newCall(request).execute() + + if (response.isSuccessful) { + val body = response.body?.string() + if (body != null) { + try { + val settings = json.decodeFromString(body) + Timber.i("Loaded plugin settings from $serverUrl") + settings + } catch (e: Exception) { + Timber.w(e, "Failed to parse plugin settings response") + null + } + } else { + Timber.w("Plugin settings endpoint returned empty body") + null + } + } else { + Timber.v("Plugin settings not available on $serverUrl (status: ${response.code})") + null + } + } catch (e: Exception) { + Timber.v(e, "Error fetching plugin settings from $serverUrl") + null + } + /** * Make an authenticated request to a Wholphin plugin endpoint * @@ -293,6 +341,21 @@ sealed class LoginBackgroundResult { object NotAvailable : LoginBackgroundResult() } +/** + * General plugin settings + * + * Example JSON from server: + * ```json + * { + * "seerrUrl": "https://your-seerr-instance.com" + * } + * ``` + */ +@Serializable +data class PluginSettings( + val seerrUrl: String? = null, +) + // ============================================================================ // Home Configuration Data Classes // ============================================================================ From a063c0a8f3eac7705b780f7bbe1d579e5cf6b536 Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Sat, 3 Jan 2026 16:18:12 +0100 Subject: [PATCH 06/18] Refactor filter parsing in HomeViewModel to use serialName lookup for improved clarity --- .../damontecres/wholphin/ui/main/HomeViewModel.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 0727e4c65..3486f6951 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -256,11 +256,12 @@ class HomeViewModel // Parse filters val filters = query.filters?.mapNotNull { filterStr -> - try { - org.jellyfin.sdk.model.api.ItemFilter.valueOf(filterStr) - } catch (e: Exception) { - Timber.w("Unknown filter: $filterStr") - null + org.jellyfin.sdk.model.api.ItemFilter.entries.find { + it.serialName == filterStr + }.also { filter -> + if (filter == null) { + Timber.w("Unknown filter: $filterStr") + } } } From 81219ccff7f90b65364ddf5a00fbcc2eb1d4e7fc Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Wed, 7 Jan 2026 15:24:21 +0100 Subject: [PATCH 07/18] Add navigation drawer configuration support to WholphinPluginService --- .../services/WholphinPluginService.kt | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt index 23ac404f6..30a67e0a5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt @@ -37,6 +37,7 @@ class WholphinPluginService private const val LOGIN_BACKGROUND_ENDPOINT = "$PLUGIN_BASE_PATH/loginbackground" private const val HOME_ENDPOINT = "$PLUGIN_BASE_PATH/home" private const val SETTINGS_ENDPOINT = "$PLUGIN_BASE_PATH/settings" + private const val NAV_DRAWER_ENDPOINT = "$PLUGIN_BASE_PATH/navdrawer" } private val json = @@ -248,6 +249,58 @@ class WholphinPluginService null } + /** + * Get navigation drawer configuration from the Wholphin plugin + * + * This endpoint requires authentication and returns the configuration for items + * displayed in the navigation drawer between Favorites and Settings. + * Allows server administrators to control visibility, ordering, and add custom shortcuts. + * + * @param serverUrl The base URL of the Jellyfin server + * @return NavDrawerConfiguration object with items, or null if not available or on error + */ + suspend fun getNavDrawerConfiguration(serverUrl: String): NavDrawerConfiguration? = + try { + val normalizedUrl = serverUrl.trimEnd('/') + val endpoint = "$normalizedUrl$NAV_DRAWER_ENDPOINT" + + val request = + Request + .Builder() + .url(endpoint) + .get() + .build() + + val response = authOkHttpClient.newCall(request).execute() + + if (response.isSuccessful) { + val body = response.body?.string() + if (body != null) { + try { + val config = json.decodeFromString(body) + Timber.i( + "Loaded nav drawer configuration from $serverUrl: ${config.items.size} items" + ) + config + } catch (e: Exception) { + Timber.w(e, "Failed to parse nav drawer configuration response") + null + } + } else { + Timber.w("Nav drawer configuration endpoint returned empty body") + null + } + } else { + Timber.v( + "Nav drawer configuration not available on $serverUrl (status: ${response.code})" + ) + null + } + } catch (e: Exception) { + Timber.v(e, "Error fetching nav drawer configuration from $serverUrl") + null + } + /** * Make an authenticated request to a Wholphin plugin endpoint * @@ -443,6 +496,92 @@ enum class HomeSectionType { CUSTOM, // 4 } + + +// ============================================================================ +// Navigation Drawer Configuration Data Classes +// ============================================================================ + +/** + * Navigation drawer configuration from the Wholphin plugin + * + * Controls the items displayed in the navigation drawer between Favorites and Settings. + * Allows server administrators to customize visibility, ordering, and add custom shortcuts + * to collections or playlists. + * + * Example JSON from server: + * ```json + * { + * "items": [ + * { + * "id": "abc-123-library-id", + * "type": "library", + * "name": "Movies", + * "order": 0, + * "visible": true + * }, + * { + * "id": "xyz-789-collection-id", + * "type": "collection", + * "name": "Netflix", + * "order": 1, + * "visible": true, + * "imageUrl": "/Items/xyz-789/Images/Primary" + * }, + * { + * "id": "def-456-library-id", + * "type": "library", + * "order": 2, + * "visible": false + * } + * ] + * } + * ``` + */ +@Serializable +data class NavDrawerConfiguration( + val items: List = emptyList(), +) + +/** + * Configuration for a single navigation drawer item + * + * @param id UUID of the Jellyfin library, collection, or playlist + * @param type Type of item (LIBRARY, COLLECTION, or PLAYLIST) + * @param name Optional display name override (if null, uses Jellyfin item name) + * @param order Sort order in the drawer (lower numbers appear first) + * @param visible If true, shown in main list; if false, hidden behind "More" button + * @param imageUrl Optional custom image URL for collection/playlist shortcuts (e.g., "/Items/{id}/Images/Primary") + */ +@Serializable +data class NavDrawerItemConfig( + val id: String, + val type: NavDrawerItemType, + val name: String? = null, + val order: Int, + val visible: Boolean = true, + val imageUrl: String? = null, +) + +/** + * Type of navigation drawer item + * + * Serialized as lowercase strings: "library", "collection", "playlist" + */ +@Serializable +enum class NavDrawerItemType { + /** Jellyfin library (Movies, TV Shows, Music, etc.) */ + @SerialName("library") + LIBRARY, + + /** Collection shortcut with optional custom icon */ + @SerialName("collection") + COLLECTION, + + /** Playlist shortcut with optional custom icon */ + @SerialName("playlist") + PLAYLIST, +} /** * Custom serializer for HomeSectionType that handles both integer and string values from C# server */ From 1fd6ecc8529d6776f91d25387eea6b151f45e860 Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Wed, 7 Jan 2026 15:41:29 +0100 Subject: [PATCH 08/18] Enhance navigation drawer with plugin support for custom items and icons --- .../wholphin/data/NavDrawerItemRepository.kt | 208 ++++++++++++++++-- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 87 +++++++- 2 files changed, 273 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt index ba0701163..6238cf561 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt @@ -4,15 +4,24 @@ import android.content.Context import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavPinType +import com.github.damontecres.wholphin.services.WholphinPluginService +import com.github.damontecres.wholphin.services.NavDrawerItemType +import com.github.damontecres.wholphin.ui.nav.CustomNavDrawerItem +import com.github.damontecres.wholphin.ui.nav.CustomNavDrawerItemType import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.serializer.toUUID +import timber.log.Timber +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -24,8 +33,68 @@ class NavDrawerItemRepository private val api: ApiClient, private val serverRepository: ServerRepository, private val serverPreferencesDao: ServerPreferencesDao, + private val pluginService: WholphinPluginService, ) { suspend fun getNavDrawerItems(): List { + val user = serverRepository.currentUser.value + val server = serverRepository.currentServer.value + + // Fetch all available Jellyfin libraries + val jellyfinLibraries = fetchJellyfinLibraries() + + // Try to fetch plugin configuration + val pluginConfig = server?.url?.let { serverUrl -> + try { + pluginService.getNavDrawerConfiguration(serverUrl) + } catch (e: Exception) { + Timber.w(e, "Error fetching nav drawer configuration") + null + } + } + + val builtins = listOf(NavDrawerItem.Favorites) + + // If plugin config is available and not empty, use it to control middle section + return if (pluginConfig != null && pluginConfig.items.isNotEmpty()) { + val visibleItems = applyPluginConfiguration(jellyfinLibraries, pluginConfig, visibleOnly = true) + builtins + visibleItems + } else { + // Fallback to existing behavior (plugin not configured or empty config) + builtins + jellyfinLibraries + } + } + + /** + * Get items that should be hidden behind the "More" button + * Returns hidden items when plugin config is active, or unpinned items when using local pins + */ + suspend fun getHiddenNavDrawerItems(): List { + val server = serverRepository.currentServer.value + + // Try to fetch plugin configuration + val pluginConfig = server?.url?.let { serverUrl -> + try { + pluginService.getNavDrawerConfiguration(serverUrl) + } catch (e: Exception) { + null + } + } + + // If plugin config is active and not empty, return hidden items + if (pluginConfig != null && pluginConfig.items.isNotEmpty()) { + val jellyfinLibraries = fetchJellyfinLibraries() + return applyPluginConfiguration(jellyfinLibraries, pluginConfig, visibleOnly = false) + } + + // Fallback: use pin-based logic (plugin not configured or empty config) + val allItems = getNavDrawerItems() + return getFilteredNavDrawerItems(allItems) + } + + /** + * Fetch all available Jellyfin libraries and recordings + */ + private suspend fun fetchJellyfinLibraries(): List { val user = serverRepository.currentUser.value val tvAccess = serverRepository.currentUserDto.value @@ -46,25 +115,132 @@ class NavDrawerItemRepository setOf() } - val builtins = listOf(NavDrawerItem.Favorites) - val libraries = - userViews - .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } - .map { - val destination = - if (it.id in recordingFolders) { - Destination.Recordings(it.id) + return userViews + .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } + .map { + val destination = + if (it.id in recordingFolders) { + Destination.Recordings(it.id) + } else { + BaseItem.from(it, api).destination() + } + ServerNavDrawerItem( + itemId = it.id, + name = it.name ?: it.id.toString(), + destination = destination, + type = it.collectionType ?: CollectionType.UNKNOWN, + ) + } + } + + /** + * Apply plugin configuration to control nav drawer items + * + * Merges Jellyfin libraries with plugin configuration, creating custom items + * for collections/playlists and respecting the order and visibility defined by the plugin. + * + * @param visibleOnly If true, returns only visible items; if false, returns only hidden items + */ + private suspend fun applyPluginConfiguration( + jellyfinLibraries: List, + pluginConfig: com.github.damontecres.wholphin.services.NavDrawerConfiguration, + visibleOnly: Boolean, + ): List { + val user = serverRepository.currentUser.value + + // Map Jellyfin libraries by ID for quick lookup + val librariesById = jellyfinLibraries.associateBy { it.itemId } + + // Process plugin configuration items + val allItems = pluginConfig.items + .sortedBy { it.order } + .mapNotNull { config -> + val itemId = try { + config.id.toUUID() + } catch (e: Exception) { + Timber.w("Invalid UUID in nav drawer config: ${config.id}") + return@mapNotNull null + } + + val item = when (config.type) { + NavDrawerItemType.LIBRARY -> { + // Use existing library item or create a basic one if not found + val existing = librariesById[itemId] + if (existing != null) { + // Override name if provided in config + if (config.name != null) { + existing.copy(name = config.name) + } else { + existing + } } else { - BaseItem.from(it, api).destination() + // Library not found in Jellyfin, skip it + Timber.w("Library $itemId from plugin config not found in Jellyfin") + null } - ServerNavDrawerItem( - itemId = it.id, - name = it.name ?: it.id.toString(), - destination = destination, - type = it.collectionType ?: CollectionType.UNKNOWN, - ) + } + + NavDrawerItemType.COLLECTION, NavDrawerItemType.PLAYLIST -> { + // Fetch the collection/playlist item from Jellyfin + val item = fetchItemById(itemId, user?.id) + if (item != null) { + val destination = BaseItem.from(item, api).destination() + val displayName = config.name ?: item.name ?: itemId.toString() + val imageUrl = config.imageUrl + + CustomNavDrawerItem( + itemId = itemId, + itemName = displayName, + destination = destination, + itemType = when (config.type) { + NavDrawerItemType.COLLECTION -> CustomNavDrawerItemType.COLLECTION + NavDrawerItemType.PLAYLIST -> CustomNavDrawerItemType.PLAYLIST + else -> CustomNavDrawerItemType.COLLECTION + }, + imageUrl = imageUrl, + ) + } else { + Timber.w("Collection/Playlist $itemId from plugin config not found in Jellyfin") + null + } + } } - return builtins + libraries + + // Return item with its visibility flag + item?.let { Pair(it, config.visible) } + } + + // Filter by visibility + val filteredItems = allItems + .filter { (_, visible) -> visible == visibleOnly } + .map { (item, _) -> item } + + // Add "More" button to visible items if there are hidden items + return if (visibleOnly) { + val hasHiddenItems = allItems.any { (_, visible) -> !visible } + if (hasHiddenItems) { + filteredItems + listOf(NavDrawerItem.More) + } else { + filteredItems + } + } else { + filteredItems + } + } + + /** + * Fetch a single item (collection or playlist) by ID + */ + private suspend fun fetchItemById(itemId: UUID, userId: UUID?): BaseItemDto? { + return try { + api.itemsApi.getItems( + userId = userId, + ids = listOf(itemId), + ).content.items.firstOrNull() + } catch (e: Exception) { + Timber.w(e, "Error fetching item $itemId") + null + } } suspend fun getFilteredNavDrawerItems(items: List): List { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index ff3a64b32..f24194b90 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -70,6 +71,7 @@ import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text import androidx.tv.material3.rememberDrawerState import androidx.tv.material3.surfaceColorAtElevation +import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.model.JellyfinServer @@ -81,6 +83,7 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchIO @@ -97,6 +100,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -120,19 +124,20 @@ class NavDrawerViewModel viewModelScope.launchIO { val all = all ?: navDrawerItemRepository.getNavDrawerItems() this@NavDrawerViewModel.all = all - val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) - val moreLibraries = all.toMutableList().apply { removeAll(libraries) } + val hiddenItems = navDrawerItemRepository.getHiddenNavDrawerItems() withContext(Dispatchers.Main) { - this@NavDrawerViewModel.moreLibraries.value = moreLibraries - this@NavDrawerViewModel.libraries.value = libraries + this@NavDrawerViewModel.moreLibraries.value = hiddenItems + this@NavDrawerViewModel.libraries.value = all } val asDestinations = - (libraries + listOf(NavDrawerItem.More) + moreLibraries).map { + (all + hiddenItems).map { if (it is ServerNavDrawerItem) { it.destination } else if (it is NavDrawerItem.Favorites) { Destination.Favorites + } else if (it is CustomNavDrawerItem) { + it.destination } else { null } @@ -205,6 +210,38 @@ data class ServerNavDrawerItem( override fun name(context: Context): String = name } +/** + * Custom navigation drawer item for collections and playlists with optional custom icons + * + * Used when the server plugin provides custom shortcuts to collections or playlists. + * Supports loading custom icons from image URLs. + * + * @param itemId UUID of the collection or playlist + * @param itemName Display name for the item + * @param destination Navigation destination + * @param itemType Type of the custom item (collection or playlist) + * @param imageUrl Optional URL for custom icon (e.g., primary image from collection) + */ +data class CustomNavDrawerItem( + val itemId: UUID, + val itemName: String, + val destination: Destination, + val itemType: CustomNavDrawerItemType, + val imageUrl: String? = null, +) : NavDrawerItem { + override val id: String = "c_" + itemId.toServerString() + + override fun name(context: Context): String = itemName +} + +/** + * Type of custom navigation drawer item + */ +enum class CustomNavDrawerItemType { + COLLECTION, + PLAYLIST, +} + /** * Display the left side navigation drawer with [DestinationContent] on the right */ @@ -271,6 +308,11 @@ fun NavDrawer( viewModel.setIndex(index) viewModel.navigationManager.navigateToFromDrawer(item.destination) } + + is CustomNavDrawerItem -> { + viewModel.setIndex(index) + viewModel.navigationManager.navigateToFromDrawer(item.destination) + } } } // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 @@ -618,6 +660,13 @@ fun NavigationDrawerScope.NavItem( else -> R.string.fa_film } } + + is CustomNavDrawerItem -> { + when (library.itemType) { + CustomNavDrawerItemType.COLLECTION -> R.string.fa_open_folder + CustomNavDrawerItemType.PLAYLIST -> R.string.fa_list_ul + } + } } val focused by interactionSource.collectIsFocusedAsState() NavigationDrawerItem( @@ -630,8 +679,34 @@ fun NavigationDrawerScope.NavItem( ), leadingContent = { val color = navItemColor(selected, focused, drawerOpen) + val imageUrlService = LocalImageUrlService.current + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - if (useFont) { + // Handle CustomNavDrawerItem with potential image + if (library is CustomNavDrawerItem) { + val imageUrl = library.imageUrl ?: imageUrlService.getItemImageUrl( + itemId = library.itemId, + imageType = ImageType.PRIMARY, + ) + + if (imageUrl != null) { + AsyncImage( + model = imageUrl, + contentDescription = library.name(context), + modifier = Modifier.size(24.dp), + ) + } else { + // Fallback to FontAwesome icon if no image available + Text( + text = stringResource(icon), + textAlign = TextAlign.Center, + fontSize = 16.sp, + fontFamily = FontAwesome, + color = color, + modifier = Modifier, + ) + } + } else if (useFont) { Text( text = stringResource(icon), textAlign = TextAlign.Center, From 9195c4a24c31f461aa219f143684c49182da551a Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Wed, 7 Jan 2026 15:42:00 +0100 Subject: [PATCH 09/18] Add RecommendedCollection and CollectionFolderCollection components --- .../ui/components/RecommendedCollection.kt | 295 ++++++++++++++++++ .../ui/detail/CollectionFolderCollection.kt | 142 +++++++++ .../wholphin/ui/nav/DestinationContent.kt | 33 +- app/src/main/res/values/strings.xml | 4 + 4 files changed, 466 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedCollection.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderCollection.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedCollection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedCollection.kt new file mode 100644 index 000000000..eb2bcf40b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedCollection.kt @@ -0,0 +1,295 @@ +package com.github.damontecres.wholphin.ui.components + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetNextUpRequestHandler +import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler +import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemFilter +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest +import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest +import timber.log.Timber + +@HiltViewModel(assistedFactory = RecommendedCollectionViewModel.Factory::class) +class RecommendedCollectionViewModel + @AssistedInject + constructor( + @ApplicationContext context: Context, + navigationManager: NavigationManager, + favoriteWatchManager: FavoriteWatchManager, + backdropService: BackdropService, + private val api: ApiClient, + private val serverRepository: ServerRepository, + @Assisted private val parentId: UUID, + ) : RecommendedViewModel( + context, + navigationManager, + favoriteWatchManager, + backdropService, + ) { + @AssistedFactory + interface Factory { + fun create(parentId: UUID): RecommendedCollectionViewModel + } + + override val rows: MutableStateFlow> = + MutableStateFlow( + listOf( + HomeRowLoadingState.Loading(context.getString(R.string.suggestions)), + HomeRowLoadingState.Loading(context.getString(R.string.top_unwatched)), + HomeRowLoadingState.Loading(context.getString(R.string.latest_series)), + HomeRowLoadingState.Loading(context.getString(R.string.latest_movies)), + HomeRowLoadingState.Loading(context.getString(R.string.continue_watching)), + HomeRowLoadingState.Loading(context.getString(R.string.next_up)), + ), + ) + + private val itemsPerRow = 20 + + override fun init() { + viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + val userId = serverRepository.currentUser.value?.id + + // Load Suggestions first (priority row) + update(R.string.suggestions) { + val movieRequest = + GetSuggestionsRequest( + userId = userId, + type = listOf(BaseItemKind.MOVIE), + startIndex = 0, + limit = itemsPerRow / 2, + enableTotalRecordCount = false, + ) + val seriesRequest = + GetSuggestionsRequest( + userId = userId, + type = listOf(BaseItemKind.SERIES), + startIndex = 0, + limit = itemsPerRow / 2, + enableTotalRecordCount = false, + ) + + val movies = + GetSuggestionsRequestHandler + .execute(api, movieRequest) + .toBaseItems(api, false) + val series = + GetSuggestionsRequestHandler + .execute(api, seriesRequest) + .toBaseItems(api, false) + + Timber.d("Suggestions - Movies: ${movies.size}, Series: ${series.size}") + val result = (movies + series).shuffled().take(itemsPerRow) + Timber.d("Suggestions - Combined: ${result.size} items") + result + } + + // Set loading to Success after first row is loaded + if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { + loading.setValueOnMain(LoadingState.Success) + } + + // Load remaining rows in parallel + update(R.string.top_unwatched) { + val moviesRequest = + GetItemsRequest( + parentId = parentId, + includeItemTypes = listOf(BaseItemKind.MOVIE), + fields = SlimItemFields, + filters = listOf(ItemFilter.IS_NOT_FOLDER), + recursive = true, + isPlayed = false, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + startIndex = 0, + limit = itemsPerRow / 2, + enableTotalRecordCount = false, + ) + val seriesRequest = + GetItemsRequest( + parentId = parentId, + includeItemTypes = listOf(BaseItemKind.SERIES), + fields = SlimItemFields, + filters = listOf(ItemFilter.IS_NOT_FOLDER), + recursive = true, + isPlayed = false, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + startIndex = 0, + limit = itemsPerRow / 2, + enableTotalRecordCount = false, + ) + + val movies = + GetItemsRequestHandler + .execute(api, moviesRequest) + .toBaseItems(api, false) + val series = + GetItemsRequestHandler + .execute(api, seriesRequest) + .toBaseItems(api, false) + + Timber.d("Top Unwatched - Movies: ${movies.size}, Series: ${series.size}") + val result = (movies + series).sortedByDescending { it.data.communityRating }.take(itemsPerRow) + Timber.d("Top Unwatched - Combined: ${result.size} items") + result + } + + // Latest Series + update(R.string.latest_series) { + val request = + GetItemsRequest( + parentId = parentId, + includeItemTypes = listOf(BaseItemKind.EPISODE), + fields = SlimItemFields, + filters = listOf(ItemFilter.IS_NOT_FOLDER), + recursive = true, + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + val result = GetItemsRequestHandler + .execute(api, request) + .toBaseItems(api, true) + Timber.d("Latest Series (Episodes): ${result.size} items") + result + } + + // Latest Movies + update(R.string.latest_movies) { + val request = + GetItemsRequest( + parentId = parentId, + includeItemTypes = listOf(BaseItemKind.MOVIE), + fields = SlimItemFields, + filters = listOf(ItemFilter.IS_NOT_FOLDER), + recursive = true, + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + val result = GetItemsRequestHandler + .execute(api, request) + .toBaseItems(api, false) + Timber.d("Latest Movies: ${result.size} items") + result + } + + // Movie Continue Watching + update(R.string.continue_watching) { + val request = + GetResumeItemsRequest( + parentId = parentId, + includeItemTypes = listOf(BaseItemKind.MOVIE), + fields = SlimItemFields, + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + val result = GetResumeItemsRequestHandler + .execute(api, request) + .toBaseItems(api, false) + Timber.d("Continue Watching (Movies): ${result.size} items") + result + } + + // Series Next Up + update(R.string.next_up) { + val request = + GetNextUpRequest( + userId = userId, + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = parentId, + limit = itemsPerRow, + enableResumable = false, + enableUserData = true, + ) + val result = GetNextUpRequestHandler + .execute(api, request) + .toBaseItems(api, true) + Timber.d("Next Up (Series): ${result.size} items") + result + } + } + } + + override fun update( + @StringRes title: Int, + row: HomeRowLoadingState, + ) { + rows.update { current -> + current.toMutableList().apply { set(rowTitles[title]!!, row) } + } + } + + companion object { + private val rowTitles = + listOf( + R.string.suggestions, + R.string.top_unwatched, + R.string.latest_series, + R.string.latest_movies, + R.string.continue_watching, + R.string.next_up, + ).mapIndexed { index, i -> i to index }.toMap() + } + } + + +@Composable +fun RecommendedCollection( + parentId: UUID, + preferences: UserPreferences, + onFocusPosition: (RowColumn) -> Unit, + modifier: Modifier = Modifier, + viewModel: RecommendedCollectionViewModel = + hiltViewModel { factory -> + factory.create(parentId) + }, +) { + RecommendedContent( + preferences = preferences, + viewModel = viewModel, + onFocusPosition = onFocusPosition, + modifier = modifier, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderCollection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderCollection.kt new file mode 100644 index 000000000..3948c8614 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderCollection.kt @@ -0,0 +1,142 @@ +package com.github.damontecres.wholphin.ui.detail + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.GenreCardGrid +import com.github.damontecres.wholphin.ui.components.RecommendedCollection +import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster +import com.github.damontecres.wholphin.ui.data.VideoSortOptions +import com.github.damontecres.wholphin.ui.logTab +import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun CollectionFolderCollection( + collectionItem: BaseItemDto, + isFavorite: Boolean, + onToggleFavorite: (BaseItemDto) -> Unit, + preferences: UserPreferences, + modifier: Modifier = Modifier, + preferencesViewModel: PreferencesViewModel = hiltViewModel(), +) { + val tabs = + listOf( + stringResource(R.string.recommended), + stringResource(R.string.content), + stringResource(R.string.genres), + ) + var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) } + val focusRequester = remember { FocusRequester() } + + val firstTabFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } + + LaunchedEffect(selectedTabIndex) { + logTab("collection", selectedTabIndex) + preferencesViewModel.backdropService.clearBackdrop() + } + + var showHeader by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + + Column(modifier = modifier.fillMaxSize()) { + AnimatedVisibility( + showHeader, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = + Modifier + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .focusRequester(firstTabFocusRequester), + tabs = tabs, + onClick = { selectedTabIndex = it }, + ) + } + + when (selectedTabIndex) { + 0 -> { + RecommendedCollection( + parentId = collectionItem.id, + preferences = preferences, + onFocusPosition = { position -> + // Handle focus position for backdrop updates + }, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + 1 -> { + CollectionFolderGrid( + preferences = preferences, + onClickItem = { _, item -> + preferencesViewModel.navigationManager.navigateTo(item.destination()) + }, + itemId = collectionItem.id, + viewModelKey = "${collectionItem.id}_content", + initialFilter = com.github.damontecres.wholphin.data.model.CollectionFolderFilter( + filter = + com.github.damontecres.wholphin.data.model.GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + ), + ), + showTitle = false, + recursive = true, + sortOptions = VideoSortOptions, + defaultViewOptions = ViewOptionsPoster, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = false, + ) + } + 2 -> { + GenreCardGrid( + itemId = collectionItem.id, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 3ebd1cb18..ba8395942 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet +import com.github.damontecres.wholphin.ui.detail.CollectionFolderCollection import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie @@ -118,14 +119,30 @@ fun DestinationContent( BaseItemKind.BOX_SET -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } - CollectionFolderBoxSet( - preferences = preferences, - itemId = destination.itemId, - item = destination.item, - recursive = false, - playEnabled = true, - modifier = modifier, - ) + + // Use CollectionFolderCollection for collections with mixed content + // (like Netflix, Disney+, etc. that contain both movies and series) + val item = destination.item + val isMixedCollection = item?.data?.collectionType == null + + if (isMixedCollection) { + CollectionFolderCollection( + collectionItem = destination.item!!.data, + isFavorite = destination.item!!.data.userData?.isFavorite ?: false, + onToggleFavorite = { /* TODO: implement favorite toggle */ }, + preferences = preferences, + modifier = modifier, + ) + } else { + CollectionFolderBoxSet( + preferences = preferences, + itemId = destination.itemId, + item = destination.item, + recursive = false, + playEnabled = true, + modifier = modifier, + ) + } } BaseItemKind.PLAYLIST -> { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d589ee14f..aaa7d0cf6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -123,6 +123,10 @@ Subtitle Subtitles Suggestions + Content + Latest Movies + Latest Series + Series Switch servers Switch Top Rated Unwatched From 6008f3b77d30fe6c52cf9e71eb8bd7a9eb0bf02c Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Thu, 15 Jan 2026 10:13:00 +0100 Subject: [PATCH 10/18] Refactor plugin capabilities checks in SwitchServerViewModel and SwitchUserViewModel --- .../services/WholphinPluginService.kt | 83 ------------------- .../ui/setup/SwitchServerViewModel.kt | 19 ++--- .../wholphin/ui/setup/SwitchUserViewModel.kt | 12 +-- 3 files changed, 11 insertions(+), 103 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt index 30a67e0a5..a7fc35527 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/WholphinPluginService.kt @@ -33,7 +33,6 @@ class WholphinPluginService ) { companion object { private const val PLUGIN_BASE_PATH = "/wholphin" - private const val CAPABILITIES_ENDPOINT = "$PLUGIN_BASE_PATH/capabilities" private const val LOGIN_BACKGROUND_ENDPOINT = "$PLUGIN_BASE_PATH/loginbackground" private const val HOME_ENDPOINT = "$PLUGIN_BASE_PATH/home" private const val SETTINGS_ENDPOINT = "$PLUGIN_BASE_PATH/settings" @@ -46,54 +45,6 @@ class WholphinPluginService isLenient = true } - /** - * Check which features/capabilities the Wholphin plugin supports - * - * This should be called first to determine what endpoints are available. - * The plugin should implement a /wholphin/capabilities endpoint that returns - * a JSON object describing supported features. - * - * @param serverUrl The base URL of the Jellyfin server - * @return PluginCapabilities object describing available features, or null if plugin is not available - */ - suspend fun getPluginCapabilities(serverUrl: String): PluginCapabilities? = - try { - val normalizedUrl = serverUrl.trimEnd('/') - val endpoint = "$normalizedUrl$CAPABILITIES_ENDPOINT" - - val request = - Request - .Builder() - .url(endpoint) - .get() - .build() - - val response = standardOkHttpClient.newCall(request).execute() - - if (response.isSuccessful) { - val body = response.body?.string() - if (body != null) { - try { - val capabilities = json.decodeFromString(body) - Timber.i("Wholphin plugin capabilities on $serverUrl: $capabilities") - capabilities - } catch (e: Exception) { - Timber.w(e, "Failed to parse plugin capabilities response") - null - } - } else { - Timber.w("Plugin capabilities endpoint returned empty body") - null - } - } else { - Timber.v("Wholphin plugin capabilities not available on $serverUrl (status: ${response.code})") - null - } - } catch (e: Exception) { - Timber.v(e, "Error checking for Wholphin plugin capabilities on $serverUrl") - null - } - /** * Check if the Wholphin plugin is available on the server and fetch the login background URL * @@ -343,40 +294,6 @@ class WholphinPluginService } } -@Serializable -data class PluginCapabilities( - val version: String = "1.0.0", - val features: Features = Features(), -) { - @Serializable - data class Features( - val loginBackground: Boolean = false, - val homeConfiguration: Boolean = false, - // Add more features here as the plugin grows - // val customThemes: Boolean = false, - // val enhancedMetadata: Boolean = false, - // val socialFeatures: Boolean = false, - ) - - /** - * Check if a specific feature is supported - */ - fun hasFeature(feature: PluginFeature): Boolean = - when (feature) { - PluginFeature.LOGIN_BACKGROUND -> features.loginBackground - PluginFeature.HOME_CONFIGURATION -> features.homeConfiguration - } -} - -/** - * Enum of all possible plugin features for type-safe feature checking - */ -enum class PluginFeature { - LOGIN_BACKGROUND, - HOME_CONFIGURATION, - // Add more features here as needed -} - @Serializable data class LoginBackgroundData( val backgroundUrl: String = "", diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index a34c1d074..bdd8559c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -10,7 +10,6 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.services.LoginBackgroundResult -import com.github.damontecres.wholphin.services.PluginFeature import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.WholphinPluginService @@ -286,17 +285,13 @@ class SwitchServerViewModel } private suspend fun checkPluginBackground(server: JellyfinServer) { - // First check if plugin is available and supports login background - val capabilities = wholphinPluginService.getPluginCapabilities(server.url) - if (capabilities?.hasFeature(PluginFeature.LOGIN_BACKGROUND) == true) { - val backgroundResult = wholphinPluginService.getLoginBackground(server.url) - if (backgroundResult is LoginBackgroundResult.Available) { - withContext(Dispatchers.Main) { - serverLoginBackground.value = - serverLoginBackground.value!!.toMutableMap().apply { - put(server.id, backgroundResult) - } - } + val backgroundResult = wholphinPluginService.getLoginBackground(server.url) + if (backgroundResult is LoginBackgroundResult.Available) { + withContext(Dispatchers.Main) { + serverLoginBackground.value = + serverLoginBackground.value!!.toMutableMap().apply { + put(server.id, backgroundResult) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index b626380bd..43dcaf775 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -10,7 +10,6 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.LoginBackgroundResult import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.PluginFeature import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.WholphinPluginService @@ -96,13 +95,10 @@ class SwitchUserViewModel } // Check for Wholphin plugin login background - val capabilities = wholphinPluginService.getPluginCapabilities(server.url) - if (capabilities?.hasFeature(PluginFeature.LOGIN_BACKGROUND) == true) { - val backgroundResult = wholphinPluginService.getLoginBackground(server.url) - if (backgroundResult is LoginBackgroundResult.Available) { - withContext(Dispatchers.Main) { - loginBackground.value = backgroundResult - } + val backgroundResult = wholphinPluginService.getLoginBackground(server.url) + if (backgroundResult is LoginBackgroundResult.Available) { + withContext(Dispatchers.Main) { + loginBackground.value = backgroundResult } } } From 7bc2c58a95326463257355693c9fc272243ccfff Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Thu, 15 Jan 2026 10:56:13 +0100 Subject: [PATCH 11/18] Add focus requesters for tab navigation and update GenreCardGrid item types --- .../wholphin/ui/detail/CollectionFolderCollection.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderCollection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderCollection.kt index 3948c8614..1571179ef 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderCollection.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderCollection.kt @@ -53,6 +53,7 @@ fun CollectionFolderCollection( ) var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) } val focusRequester = remember { FocusRequester() } + val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } val firstTabFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } @@ -79,6 +80,7 @@ fun CollectionFolderCollection( .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) .focusRequester(firstTabFocusRequester), tabs = tabs, + focusRequesters = tabFocusRequesters, onClick = { selectedTabIndex = it }, ) } @@ -88,7 +90,7 @@ fun CollectionFolderCollection( RecommendedCollection( parentId = collectionItem.id, preferences = preferences, - onFocusPosition = { position -> + onFocusPosition = { _ -> // Handle focus position for backdrop updates }, modifier = @@ -130,6 +132,7 @@ fun CollectionFolderCollection( 2 -> { GenreCardGrid( itemId = collectionItem.id, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), modifier = Modifier .padding(start = 16.dp) From 219b9ed912d94d9b16d0d694bea7b88d1c7632f0 Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Thu, 15 Jan 2026 11:27:09 +0100 Subject: [PATCH 12/18] Add plugin Seerr URL handling in Preferences and related components to automaticlally populate seerr url --- .../ui/preferences/PreferencesContent.kt | 6 ++++++ .../ui/preferences/PreferencesViewModel.kt | 16 ++++++++++++++++ .../wholphin/ui/setup/seerr/AddSeerrServer.kt | 6 ++++-- .../ui/setup/seerr/AddSeerrServerDialog.kt | 3 +++ .../ui/setup/seerr/SwitchSeerrViewModel.kt | 7 +++++++ 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 954322831..0e60fdfa7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -92,6 +92,7 @@ fun PreferencesContent( val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) + val pluginSeerrUrl by viewModel.pluginSeerrUrl.collectAsState() var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) } LaunchedEffect(Unit) { @@ -99,6 +100,9 @@ fun PreferencesContent( preferences = it } } + LaunchedEffect(Unit) { + viewModel.fetchPluginSeerrUrl() + } var updateCache by remember { mutableStateOf(false) } LaunchedEffect(updateCache) { val imageUsedMemory = context.imageLoader.memoryCache?.size ?: 0L @@ -398,6 +402,7 @@ fun PreferencesContent( seerrDialogMode = SeerrDialogMode.Remove } else { seerrVm.resetStatus() + seerrVm.setInitialUrl(pluginSeerrUrl) seerrDialogMode = SeerrDialogMode.Add } }, @@ -506,6 +511,7 @@ fun PreferencesContent( } }, onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, + initialUrl = seerrVm.initialSeerrUrl, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 9973bbec0..f2dbc304e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.WholphinPluginService import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.NavDrawerItem @@ -27,6 +28,8 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo @@ -47,6 +50,7 @@ class PreferencesViewModel private val navDrawerItemRepository: NavDrawerItemRepository, private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, + private val wholphinPluginService: WholphinPluginService, private val deviceInfo: DeviceInfo, private val clientInfo: ClientInfo, ) : ViewModel(), @@ -56,6 +60,9 @@ class PreferencesViewModel val currentUser get() = serverRepository.currentUser + private val _pluginSeerrUrl = MutableStateFlow(null) + val pluginSeerrUrl: StateFlow = _pluginSeerrUrl + val seerrEnabled = seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser -> seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId @@ -123,6 +130,15 @@ class PreferencesViewModel } } + fun fetchPluginSeerrUrl() { + viewModelScope.launchIO { + serverRepository.currentServer.value?.let { server -> + val pluginSettings = wholphinPluginService.getPluginSettings(server.url) + _pluginSeerrUrl.value = pluginSettings?.seerrUrl + } + } + } + companion object { suspend fun resetSubtitleSettings(appPreferences: DataStore) { appPreferences.updateData { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index 0c74f715e..32fa4fd9a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -42,6 +42,7 @@ fun AddSeerrServerApiKey( onSubmit: (url: String, apiKey: String) -> Unit, status: LoadingState, modifier: Modifier = Modifier, + initialUrl: String? = null, ) { var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) } Column( @@ -52,7 +53,7 @@ fun AddSeerrServerApiKey( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf("") } + var url by remember { mutableStateOf(initialUrl ?: "") } var apiKey by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } @@ -147,6 +148,7 @@ fun AddSeerrServerUsername( username: String, status: LoadingState, modifier: Modifier = Modifier, + initialUrl: String? = null, ) { var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) } Column( @@ -157,7 +159,7 @@ fun AddSeerrServerUsername( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf("") } + var url by remember { mutableStateOf(initialUrl ?: "") } var username by remember { mutableStateOf(username) } var password by remember { mutableStateOf("") } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt index 0feef21cf..f103a5d7a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -19,6 +19,7 @@ fun AddSeerServerDialog( status: LoadingState, onSubmit: (url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onDismissRequest: () -> Unit, + initialUrl: String? = null, ) { var authMethod by remember { mutableStateOf(null) } LaunchedEffect(status) { @@ -39,6 +40,7 @@ fun AddSeerServerDialog( }, username = currentUsername ?: "", status = status, + initialUrl = initialUrl, ) } } @@ -52,6 +54,7 @@ fun AddSeerServerDialog( onSubmit.invoke(url, null, apiKey, SeerrAuthMethod.API_KEY) }, status = status, + initialUrl = initialUrl, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 0b067287d..e939038b3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -27,6 +27,13 @@ class SwitchSeerrViewModel val currentUser = serverRepository.currentUser val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) + + var initialSeerrUrl: String? = null + private set + + fun setInitialUrl(url: String?) { + initialSeerrUrl = url + } private fun cleanUrl(url: String) = if (!url.endsWith("/api/v1")) { From b9d4ece7a58eb45ae1e11e1381c4c02b5526c39e Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Fri, 16 Jan 2026 11:14:26 +0100 Subject: [PATCH 13/18] cleanup unused --- .../ui/detail/CollectionFolderCollection.kt | 145 ------------------ .../wholphin/ui/nav/DestinationContent.kt | 1 - 2 files changed, 146 deletions(-) delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderCollection.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderCollection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderCollection.kt deleted file mode 100644 index 1571179ef..000000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderCollection.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid -import com.github.damontecres.wholphin.ui.components.GenreCardGrid -import com.github.damontecres.wholphin.ui.components.RecommendedCollection -import com.github.damontecres.wholphin.ui.components.TabRow -import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster -import com.github.damontecres.wholphin.ui.data.VideoSortOptions -import com.github.damontecres.wholphin.ui.logTab -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import com.github.damontecres.wholphin.ui.tryRequestFocus -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind - -@Composable -fun CollectionFolderCollection( - collectionItem: BaseItemDto, - isFavorite: Boolean, - onToggleFavorite: (BaseItemDto) -> Unit, - preferences: UserPreferences, - modifier: Modifier = Modifier, - preferencesViewModel: PreferencesViewModel = hiltViewModel(), -) { - val tabs = - listOf( - stringResource(R.string.recommended), - stringResource(R.string.content), - stringResource(R.string.genres), - ) - var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - - val firstTabFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - - LaunchedEffect(selectedTabIndex) { - logTab("collection", selectedTabIndex) - preferencesViewModel.backdropService.clearBackdrop() - } - - var showHeader by rememberSaveable { mutableStateOf(true) } - - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - - Column(modifier = modifier.fillMaxSize()) { - AnimatedVisibility( - showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs, - focusRequesters = tabFocusRequesters, - onClick = { selectedTabIndex = it }, - ) - } - - when (selectedTabIndex) { - 0 -> { - RecommendedCollection( - parentId = collectionItem.id, - preferences = preferences, - onFocusPosition = { _ -> - // Handle focus position for backdrop updates - }, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - ) - } - 1 -> { - CollectionFolderGrid( - preferences = preferences, - onClickItem = { _, item -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - }, - itemId = collectionItem.id, - viewModelKey = "${collectionItem.id}_content", - initialFilter = com.github.damontecres.wholphin.data.model.CollectionFolderFilter( - filter = - com.github.damontecres.wholphin.data.model.GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), - ), - ), - showTitle = false, - recursive = true, - sortOptions = VideoSortOptions, - defaultViewOptions = ViewOptionsPoster, - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - ) - } - 2 -> { - GenreCardGrid( - itemId = collectionItem.id, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), - modifier = - Modifier - .padding(start = 16.dp) - .fillMaxSize() - .focusRequester(focusRequester), - ) - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 513768ce5..43b8841d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -12,7 +12,6 @@ import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet -import com.github.damontecres.wholphin.ui.detail.CollectionFolderCollection import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie From 6bd11872e8887882919edd23eee7cc96d836a223 Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Fri, 16 Jan 2026 11:24:51 +0100 Subject: [PATCH 14/18] Load sections in parallel and update ui individually --- .../wholphin/ui/main/HomeViewModel.kt | 76 +++++++++++-------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 3486f6951..d2e7ecefd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -140,42 +140,56 @@ class HomeViewModel refreshState.setValueOnMain(LoadingState.Success) - // Load each section based on its type - val loadedRows = config.sections.map { section -> - try { - val items = when (section.type) { - com.github.damontecres.wholphin.services.HomeSectionType.RESUME -> - loadResumeSection(userId, section.limit) - - com.github.damontecres.wholphin.services.HomeSectionType.NEXT_UP -> - loadNextUpSection(userId, section) + // Load each section in parallel and update individually + config.sections.forEachIndexed { index, section -> + viewModelScope.launch(Dispatchers.IO) { + try { + val items = when (section.type) { + com.github.damontecres.wholphin.services.HomeSectionType.RESUME -> + loadResumeSection(userId, section.limit) + + com.github.damontecres.wholphin.services.HomeSectionType.NEXT_UP -> + loadNextUpSection(userId, section) + + com.github.damontecres.wholphin.services.HomeSectionType.LATEST -> + loadLatestSection(userId, section) + + com.github.damontecres.wholphin.services.HomeSectionType.ITEMS -> + loadItemsSection(userId, section) + + com.github.damontecres.wholphin.services.HomeSectionType.CUSTOM -> + loadCustomSection(section) + } - com.github.damontecres.wholphin.services.HomeSectionType.LATEST -> - loadLatestSection(userId, section) + val newRow = if (items.isNotEmpty()) { + HomeRowLoadingState.Success(section.title, items) + } else { + // Keep empty sections visible in UI + HomeRowLoadingState.Success(section.title, emptyList()) + } - com.github.damontecres.wholphin.services.HomeSectionType.ITEMS -> - loadItemsSection(userId, section) + // Update this specific row in the UI as soon as it's loaded + withContext(Dispatchers.Main) { + latestRows.value = latestRows.value?.toMutableList()?.apply { + set(index, newRow) + } + } + } catch (e: Exception) { + Timber.e(e, "Error loading section ${section.id} (${section.type})") + val errorRow = HomeRowLoadingState.Error( + title = section.title, + exception = e + ) - com.github.damontecres.wholphin.services.HomeSectionType.CUSTOM -> - loadCustomSection(section) - } - - if (items.isNotEmpty()) { - HomeRowLoadingState.Success(section.title, items) - } else { - null // Skip empty sections + // Update this specific row with error in the UI + withContext(Dispatchers.Main) { + latestRows.value = latestRows.value?.toMutableList()?.apply { + set(index, errorRow) + } + } } - } catch (e: Exception) { - Timber.e(e, "Error loading section ${section.id} (${section.type})") - HomeRowLoadingState.Error( - title = section.title, - exception = e - ) } - }.filterNotNull() - - // Update UI with loaded sections - latestRows.setValueOnMain(loadedRows) + } } /** From 3b424174fab238225387f1ce7bc819f70fb29840 Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Fri, 16 Jan 2026 12:03:21 +0100 Subject: [PATCH 15/18] add advanced boxset view mode --- .../wholphin/preferences/AppPreference.kt | 14 + .../ui/components/RecommendedBoxSet.kt | 235 +++++++++++++++++ .../ui/detail/CollectionFolderBoxSet.kt | 243 ++++++++++++++++++ app/src/main/proto/WholphinDataStore.proto | 6 + app/src/main/res/values/strings.xml | 7 + 5 files changed, 505 insertions(+) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedBoxSet.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index c15b7bc23..8f71c76cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -450,6 +450,19 @@ sealed interface AppPreference { valueToIndex = { if (it != AppThemeColors.UNRECOGNIZED) it.number else 0 }, ) + val BoxSetViewModePref = + AppChoicePreference( + title = R.string.boxset_view_mode, + defaultValue = BoxSetViewMode.DEFAULT_GRID, + getter = { it.interfacePreferences.boxsetViewMode }, + setter = { prefs, value -> + prefs.updateInterfacePreferences { boxsetViewMode = value } + }, + displayValues = R.array.boxset_view_modes, + indexToValue = { BoxSetViewMode.forNumber(it) }, + valueToIndex = { if (it != BoxSetViewMode.UNRECOGNIZED) it.number else 0 }, + ) + val InstalledVersion = AppClickablePreference( title = R.string.installed_version, @@ -895,6 +908,7 @@ val basicPreferences = AppPreference.RememberSelectedTab, AppPreference.SubtitleStyle, AppPreference.ThemeColors, + AppPreference.BoxSetViewModePref, ), ), PreferenceGroup( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedBoxSet.kt new file mode 100644 index 000000000..b997f49a0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedBoxSet.kt @@ -0,0 +1,235 @@ +package com.github.damontecres.wholphin.ui.components + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.datastore.core.DataStore +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest +import timber.log.Timber +import java.util.UUID + +@HiltViewModel(assistedFactory = RecommendedBoxSetViewModel.Factory::class) +class RecommendedBoxSetViewModel + @AssistedInject + constructor( + @ApplicationContext context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val preferencesDataStore: DataStore, + @Assisted val parentId: UUID, + navigationManager: NavigationManager, + favoriteWatchManager: FavoriteWatchManager, + backdropService: BackdropService, + ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) { + @AssistedFactory + interface Factory { + fun create(parentId: UUID): RecommendedBoxSetViewModel + } + + override val rows = + MutableStateFlow>( + rowTitles.keys.map { + HomeRowLoadingState.Pending( + context.getString(it), + ) + }, + ) + + override fun init() { + viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + val itemsPerRow = + preferencesDataStore.data + .firstOrNull() + ?.homePagePreferences + ?.maxItemsPerRow + ?: AppPreference.HomePageItems.defaultValue.toInt() + try { + // Recently Released - Mixed Movies and Series + update(R.string.recently_released) { + val recentlyReleasedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.PREMIERE_DATE), + sortOrder = listOf(SortOrder.DESCENDING), + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + GetItemsRequestHandler + .execute(api, recentlyReleasedRequest) + .toBaseItems(api, false) + } + + if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { + loading.setValueOnMain(LoadingState.Success) + } + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching boxset recommendations") + withContext(Dispatchers.Main) { + loading.value = LoadingState.Error(ex) + } + } + + // Recently Added - Mixed Movies and Series + update(R.string.recently_added) { + val recentlyAddedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + GetItemsRequestHandler + .execute(api, recentlyAddedRequest) + .toBaseItems(api, false) + } + + // Suggestions - Mixed Movies and Series + update(R.string.suggestions) { + val userId = serverRepository.currentUser.value?.id + val movieRequest = + GetSuggestionsRequest( + userId = userId, + type = listOf(BaseItemKind.MOVIE), + startIndex = 0, + limit = itemsPerRow / 2, + enableTotalRecordCount = false, + ) + val seriesRequest = + GetSuggestionsRequest( + userId = userId, + type = listOf(BaseItemKind.SERIES), + startIndex = 0, + limit = itemsPerRow / 2, + enableTotalRecordCount = false, + ) + + val movies = + GetSuggestionsRequestHandler + .execute(api, movieRequest) + .toBaseItems(api, false) + val series = + GetSuggestionsRequestHandler + .execute(api, seriesRequest) + .toBaseItems(api, true) + + Timber.d("BoxSet Suggestions - Movies: ${movies.size}, Series: ${series.size}") + val result = (movies + series).shuffled().take(itemsPerRow) + Timber.d("BoxSet Suggestions - Combined: ${result.size} items") + result + } + + // Top Rated Unwatched - Mixed Movies and Series + update(R.string.top_unwatched) { + val unwatchedTopRatedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + recursive = true, + enableUserData = true, + isPlayed = false, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + startIndex = 0, + limit = itemsPerRow, + enableTotalRecordCount = false, + ) + GetItemsRequestHandler + .execute(api, unwatchedTopRatedRequest) + .toBaseItems(api, false) + } + + if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { + loading.setValueOnMain(LoadingState.Success) + } + } + } + + override fun update( + @StringRes title: Int, + row: HomeRowLoadingState, + ) { + rows.update { current -> + current.toMutableList().apply { set(rowTitles[title]!!, row) } + } + } + + companion object { + private val rowTitles = + listOf( + R.string.recently_released, + R.string.recently_added, + R.string.suggestions, + R.string.top_unwatched, + ).mapIndexed { index, i -> i to index }.toMap() + } + } + +/** + * The "recommended" tab of a boxset + */ +@Composable +fun RecommendedBoxSet( + preferences: UserPreferences, + parentId: UUID, + onFocusPosition: (RowColumn) -> Unit, + modifier: Modifier = Modifier, + viewModel: RecommendedBoxSetViewModel = + hiltViewModel( + creationCallback = { it.create(parentId) }, + ), +) { + RecommendedContent( + preferences = preferences, + viewModel = viewModel, + onFocusPosition = onFocusPosition, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt index 91cd66f78..6a98ae3ea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt @@ -1,25 +1,88 @@ package com.github.damontecres.wholphin.ui.detail +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.preferences.BoxSetViewMode import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.RecommendedBoxSet +import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder +import timber.log.Timber import java.util.UUID +@HiltViewModel(assistedFactory = BoxSetViewModel.Factory::class) +class BoxSetViewModel + @AssistedInject + constructor( + private val api: ApiClient, + @Assisted val itemId: UUID, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): BoxSetViewModel + } + + val loading = MutableLiveData(LoadingState.Loading) + val boxSetItem = MutableLiveData(null) + + init { + viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching BoxSet item")) { + val item = api.userLibraryApi.getItem(itemId).content.let { + BaseItem.from(it, api) + } + boxSetItem.setValueOnMain(item) + loading.setValueOnMain(LoadingState.Success) + } + } + } + @Composable fun CollectionFolderBoxSet( preferences: UserPreferences, @@ -29,6 +92,60 @@ fun CollectionFolderBoxSet( filter: CollectionFolderFilter = CollectionFolderFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), playEnabled: Boolean = false, +) { + val boxSetViewMode = preferences.appPreferences.interfacePreferences.boxsetViewMode + + when (boxSetViewMode) { + BoxSetViewMode.DEFAULT_GRID -> { + // Original default grid implementation + CollectionFolderBoxSetDefaultGrid( + preferences = preferences, + itemId = itemId, + recursive = recursive, + filter = filter, + preferencesViewModel = preferencesViewModel, + playEnabled = playEnabled, + modifier = modifier, + ) + } + + BoxSetViewMode.ADVANCED_VIEW -> { + // New advanced view with tabs + CollectionFolderBoxSetAdvanced( + preferences = preferences, + itemId = itemId, + recursive = recursive, + filter = filter, + preferencesViewModel = preferencesViewModel, + playEnabled = playEnabled, + modifier = modifier, + ) + } + + else -> { + // Fallback to default grid + CollectionFolderBoxSetDefaultGrid( + preferences = preferences, + itemId = itemId, + recursive = recursive, + filter = filter, + preferencesViewModel = preferencesViewModel, + playEnabled = playEnabled, + modifier = modifier, + ) + } + } +} + +@Composable +private fun CollectionFolderBoxSetDefaultGrid( + preferences: UserPreferences, + itemId: UUID, + recursive: Boolean, + filter: CollectionFolderFilter, + preferencesViewModel: PreferencesViewModel, + playEnabled: Boolean, + modifier: Modifier, ) { var showHeader by remember { mutableStateOf(true) } CollectionFolderGrid( @@ -50,3 +167,129 @@ fun CollectionFolderBoxSet( playEnabled = playEnabled, ) } + +@Composable +private fun CollectionFolderBoxSetAdvanced( + preferences: UserPreferences, + itemId: UUID, + recursive: Boolean, + filter: CollectionFolderFilter, + preferencesViewModel: PreferencesViewModel, + playEnabled: Boolean, + modifier: Modifier, +) { + // Fetch BoxSet item to get name + val boxSetViewModel: BoxSetViewModel = + hiltViewModel( + creationCallback = { it.create(itemId) }, + ) + val boxSetItem by boxSetViewModel.boxSetItem.observeAsState() + val loading by boxSetViewModel.loading.observeAsState(LoadingState.Loading) + + when (loading) { + is LoadingState.Error -> { + ErrorMessage(loading as LoadingState.Error) + } + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage() + } + LoadingState.Success -> { + val boxSetName = boxSetItem?.name ?: stringResource(R.string.collection) + + val rememberedTabIndex = + remember { preferencesViewModel.getRememberedTab(preferences, itemId, 0) } + + val tabs = + listOf( + stringResource(R.string.recommended_boxset_name, boxSetName), + stringResource(R.string.library), + ) + var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } + val focusRequester = remember { FocusRequester() } + val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } + + val firstTabFocusRequester = remember { FocusRequester() } + + LaunchedEffect(selectedTabIndex) { + logTab("boxset", selectedTabIndex) + preferencesViewModel.saveRememberedTab(preferences, itemId, selectedTabIndex) + preferencesViewModel.backdropService.clearBackdrop() + } + + var showHeader by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + + Column( + modifier = modifier, + ) { + AnimatedVisibility( + showHeader, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = + Modifier + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .focusRequester(firstTabFocusRequester), + tabs = tabs, + onClick = { selectedTabIndex = it }, + focusRequesters = tabFocusRequesters, + ) + } + when (selectedTabIndex) { + // Recommended tab + 0 -> { + RecommendedBoxSet( + preferences = preferences, + parentId = itemId, + onFocusPosition = { pos -> + showHeader = pos.row < 1 + }, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + + // Library tab + 1 -> { + CollectionFolderGrid( + preferences = preferences, + onClickItem = { _, item -> + preferencesViewModel.navigationManager.navigateTo(item.destination()) + }, + itemId = itemId, + viewModelKey = "${itemId}_library", + initialFilter = filter, + showTitle = false, + recursive = recursive, + sortOptions = BoxSetSortOptions, + defaultViewOptions = ViewOptionsPoster, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = playEnabled, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + ) + } + + else -> { + ErrorMessage("Invalid tab index $selectedTabIndex", null) + } + } + } + } + } +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index f68ecae81..ee552e02b 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -140,6 +140,11 @@ enum BackdropStyle{ BACKDROP_NONE = 2; } +enum BoxSetViewMode { + DEFAULT_GRID = 0; + ADVANCED_VIEW = 1; +} + message InterfacePreferences { ThemeSongVolume play_theme_songs = 1; bool remember_selected_tab = 2; @@ -150,6 +155,7 @@ message InterfacePreferences { SubtitlePreferences subtitles_preferences = 7; LiveTvPreferences live_tv_preferences = 8; BackdropStyle backdrop_style = 9; + BoxSetViewMode boxset_view_mode = 10; } message AdvancedPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a25e5a3c2..ade5c26e2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,7 @@ Birthplace Bitrate Born + BoxSet View Mode Cancel Recording Cancel Series Recording Cancel @@ -96,6 +97,7 @@ Recently Recorded Recently Released Recommended + Recommended: %1$s Record Program Record Series Unfavorite @@ -558,4 +560,9 @@ None + + Default Grid + Advanced View + + From c275e4c5552e60b4de868bc07f39a954ed4bcefe Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Sat, 17 Jan 2026 09:31:29 +0100 Subject: [PATCH 16/18] =?UTF-8?q?enable=20seerr=20network=20(streaming=20p?= =?UTF-8?q?rovider)=20discovery=20based=20on=20boxset=C2=B4s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/components/DiscoverNetworkTab.kt | 257 ++++++++++++++++++ .../ui/detail/CollectionFolderBoxSet.kt | 61 ++++- .../ui/preferences/PreferencesViewModel.kt | 2 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/DiscoverNetworkTab.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DiscoverNetworkTab.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DiscoverNetworkTab.kt new file mode 100644 index 000000000..0e02450cd --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DiscoverNetworkTab.kt @@ -0,0 +1,257 @@ +package com.github.damontecres.wholphin.ui.components + +import android.content.Context +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import timber.log.Timber + +data class NetworkDiscoverGridItem( + val item: DiscoverItem, +) : CardGridItem { + override val gridId: String + get() = item.id.toString() + override val playable: Boolean + get() = false + override val sortName: String + get() = item.title ?: "" +} + +data class NetworkDiscoverState( + val data: List = emptyList(), + val loading: DataLoadingState = DataLoadingState.Pending, + val hasMore: Boolean = true, + val currentPage: Int = 1, +) + +@HiltViewModel(assistedFactory = DiscoverNetworkViewModel.Factory::class) +class DiscoverNetworkViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + private val seerrService: SeerrService, + val navigationManager: NavigationManager, + private val backdropService: BackdropService, + @Assisted private val networkId: String, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(networkId: String): DiscoverNetworkViewModel + } + + val state = MutableStateFlow(NetworkDiscoverState()) + + init { + loadPage(1) + } + + fun loadPage(page: Int) { + if (state.value.loading is DataLoadingState.Loading) { + Timber.d("DiscoverNetwork: Skipping page $page - already loading") + return // Prevent duplicate loads + } + + Timber.d("DiscoverNetwork: Loading page $page") + viewModelScope.launchIO { + state.update { it.copy(loading = DataLoadingState.Loading) } + try { + val response = seerrService.api.searchApi.discoverTvNetworkNetworkIdGet( + networkId = networkId, + page = page, + ) + + val items = response.results + ?.map { DiscoverItem(it) } + ?.map { NetworkDiscoverGridItem(it) } + .orEmpty() + + val hasMore = (response.page ?: 0) < (response.totalPages ?: 0) + val oldDataSize = state.value.data.size + + state.update { + it.copy( + data = if (page == 1) items else it.data + items, + loading = DataLoadingState.Success(Unit), + hasMore = hasMore, + currentPage = page, + ) + } + Timber.d("DiscoverNetwork: Page $page loaded - data size: $oldDataSize -> ${state.value.data.size}, hasMore: $hasMore") + } catch (ex: Exception) { + Timber.e(ex, "Error loading network discovery page $page") + state.update { it.copy(loading = DataLoadingState.Error(ex)) } + } + } + } + + fun loadNextPage() { + if (state.value.hasMore) { + Timber.d("DiscoverNetwork: loadNextPage triggered - current page: ${state.value.currentPage}") + loadPage(state.value.currentPage + 1) + } else { + Timber.d("DiscoverNetwork: loadNextPage called but hasMore=false") + } + } + + fun updateBackdrop(item: DiscoverItem?) { + viewModelScope.launchIO { + if (item != null) { + backdropService.submit("discover_${item.id}", item.backDropUrl) + } else { + backdropService.clearBackdrop() + } + } + } + } + +@Composable +fun DiscoverNetworkTab( + networkId: String, + modifier: Modifier = Modifier, + focusRequesterOnEmpty: FocusRequester? = null, + viewModel: DiscoverNetworkViewModel = + hiltViewModel( + creationCallback = { it.create(networkId) }, + ), +) { + val state by viewModel.state.collectAsState() + val hasRequestedFocus = remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.updateBackdrop(null) + } + + // Show error or grid with data + if (state.loading is DataLoadingState.Error && state.data.isEmpty()) { + // Only show error if we have no data + ErrorMessage( + message = stringResource(R.string.error_loading_network_discover), + exception = (state.loading as DataLoadingState.Error).exception, + modifier = modifier, + ) + } else if (state.data.isNotEmpty()) { + // Show grid as soon as we have data, regardless of loading state + ShowNetworkGrid( + state = state, + viewModel = viewModel, + focusRequesterOnEmpty = focusRequesterOnEmpty, + hasRequestedFocus = hasRequestedFocus, + modifier = modifier, + ) + } else { + // Show loading page only when we have no data yet + LoadingPage(modifier = modifier) + } +} + +@Composable +private fun ShowNetworkGrid( + state: NetworkDiscoverState, + viewModel: DiscoverNetworkViewModel, + focusRequesterOnEmpty: FocusRequester?, + hasRequestedFocus: MutableState, + modifier: Modifier, +) { + val focusRequester = remember { FocusRequester() } + + Timber.d("DiscoverNetwork: ShowNetworkGrid recomposed - data size: ${state.data.size}, hasRequestedFocus: ${hasRequestedFocus.value}") + + LaunchedEffect(state.data.isNotEmpty()) { + Timber.d("DiscoverNetwork: LaunchedEffect triggered - isEmpty: ${state.data.isEmpty()}, hasRequestedFocus: ${hasRequestedFocus.value}") + if (!hasRequestedFocus.value) { + if (state.data.isNotEmpty()) { + Timber.d("DiscoverNetwork: Requesting focus on grid") + focusRequester.tryRequestFocus() + hasRequestedFocus.value = true + } else { + Timber.d("DiscoverNetwork: Requesting focus on empty fallback") + focusRequesterOnEmpty?.tryRequestFocus() + } + } + } + + if (state.data.isEmpty()) { + Column(modifier = modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxSize(), + ) + } + } else { + CardGrid( + pager = state.data, + onClickItem = { _: Int, item: NetworkDiscoverGridItem -> + viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item.item)) + }, + onLongClickItem = { _: Int, _: NetworkDiscoverGridItem -> }, + onClickPlay = { _, _ -> }, + letterPosition = { _: Char -> 0 }, + gridFocusRequester = focusRequester, + showJumpButtons = false, + showLetterButtons = false, + spacing = 16.dp, + positionCallback = { _, position -> + // Load next page when scrolling near the end + val threshold = state.data.size - 18 // 3 rows of 6 items + Timber.d("DiscoverNetwork: positionCallback - position: $position, threshold: $threshold, dataSize: ${state.data.size}") + if (position >= threshold && + state.hasMore && + state.loading !is DataLoadingState.Loading) { + Timber.d("DiscoverNetwork: Position threshold reached, triggering loadNextPage") + viewModel.loadNextPage() + } + }, + cardContent = { item: NetworkDiscoverGridItem?, onClick, onLongClick, mod -> + item?.let { + DiscoverItemCard( + item = it.item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + } + }, + columns = 6, + modifier = modifier, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt index 6a98ae3ea..5dd919bcb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt @@ -31,7 +31,9 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.BoxSetViewMode import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.DiscoverNetworkTab import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.RecommendedBoxSet @@ -50,6 +52,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.ItemSortBy @@ -178,7 +181,7 @@ private fun CollectionFolderBoxSetAdvanced( playEnabled: Boolean, modifier: Modifier, ) { - // Fetch BoxSet item to get name + // Fetch BoxSet item to get name and tags val boxSetViewModel: BoxSetViewModel = hiltViewModel( creationCallback = { it.create(itemId) }, @@ -198,17 +201,44 @@ private fun CollectionFolderBoxSetAdvanced( LoadingState.Success -> { val boxSetName = boxSetItem?.name ?: stringResource(R.string.collection) + // Extract network_id from tags (e.g., "network_id:213" -> "213") + val networkId = remember(boxSetItem) { + boxSetItem?.data?.tags + ?.firstOrNull { it.startsWith("network_id:", ignoreCase = true) } + ?.substringAfter(":", "") + ?.takeIf { it.isNotBlank() } + } + + // Check if Seerr is active + var seerrActive by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + seerrActive = preferencesViewModel.seerrService.active.first() + } + + // Show Discover tab only if network_id exists and Seerr is active + val showDiscoverTab = networkId != null && seerrActive + val rememberedTabIndex = remember { preferencesViewModel.getRememberedTab(preferences, itemId, 0) } - val tabs = - listOf( - stringResource(R.string.recommended_boxset_name, boxSetName), - stringResource(R.string.library), - ) + val tabs = buildList { + add(stringResource(R.string.recommended_boxset_name, boxSetName)) + add(stringResource(R.string.library)) + if (showDiscoverTab) { + add(stringResource(R.string.discover)) + } + } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } + + // Clamp selectedTabIndex to valid range when tabs change + LaunchedEffect(tabs.size) { + if (selectedTabIndex >= tabs.size) { + selectedTabIndex = 0 + } + } + val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } + val tabFocusRequesters = remember(tabs.size) { List(tabs.size) { FocusRequester() } } val firstTabFocusRequester = remember { FocusRequester() } @@ -285,6 +315,23 @@ private fun CollectionFolderBoxSetAdvanced( ) } + // Discover tab (only shown if network_id exists and Seerr is active) + 2 -> { + if (showDiscoverTab && networkId != null) { + DiscoverNetworkTab( + networkId = networkId, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + ) + } else { + ErrorMessage("Invalid tab index $selectedTabIndex", null) + } + } + else -> { ErrorMessage("Invalid tab index $selectedTabIndex", null) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index f2dbc304e..fec519575 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.WholphinPluginService import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO @@ -50,6 +51,7 @@ class PreferencesViewModel private val navDrawerItemRepository: NavDrawerItemRepository, private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, + val seerrService: SeerrService, private val wholphinPluginService: WholphinPluginService, private val deviceInfo: DeviceInfo, private val clientInfo: ClientInfo, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ade5c26e2..08633f36f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,6 +42,7 @@ Enter server address Episodes Error loading collection %1$s + Error loading network discover External Favorites Size From 7ed23e2fbf604500f5b91231e0e3862ce4e16a3e Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Wed, 21 Jan 2026 18:26:02 +0100 Subject: [PATCH 17/18] Update default box set view mode preference to advanced view --- .../github/damontecres/wholphin/preferences/AppPreference.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 096196280..38187296c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -465,7 +465,7 @@ sealed interface AppPreference { val BoxSetViewModePref = AppChoicePreference( title = R.string.boxset_view_mode, - defaultValue = BoxSetViewMode.DEFAULT_GRID, + defaultValue = BoxSetViewMode.ADVANCED_VIEW, getter = { it.interfacePreferences.boxsetViewMode }, setter = { prefs, value -> prefs.updateInterfacePreferences { boxsetViewMode = value } From 7ba263ad2e5bf4cca721bb602a66d0e5b94f6cc4 Mon Sep 17 00:00:00 2001 From: Kamil Kosek Date: Wed, 21 Jan 2026 18:26:52 +0100 Subject: [PATCH 18/18] client side filtering recommendations, to only show items included in the box set --- .../ui/components/RecommendedBoxSet.kt | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedBoxSet.kt index b997f49a0..c17a9d1d0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedBoxSet.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedBoxSet.kt @@ -130,15 +130,39 @@ class RecommendedBoxSetViewModel .toBaseItems(api, false) } - // Suggestions - Mixed Movies and Series + // Suggestions - Mixed Movies and Series (filtered to BoxSet items) update(R.string.suggestions) { val userId = serverRepository.currentUser.value?.id + + // First, fetch all items in this BoxSet to create a filter set + val boxSetItemsRequest = + GetItemsRequest( + parentId = parentId, + fields = listOf(), + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + recursive = true, + enableUserData = false, + startIndex = 0, + limit = null, // Get all items + enableTotalRecordCount = false, + ) + val boxSetItemIds = + GetItemsRequestHandler + .execute(api, boxSetItemsRequest) + .content + .items + .mapNotNull { it.id } + .toSet() + + Timber.d("BoxSet has ${boxSetItemIds.size} items for filtering") + + // Fetch more suggestions to compensate for filtering val movieRequest = GetSuggestionsRequest( userId = userId, type = listOf(BaseItemKind.MOVIE), startIndex = 0, - limit = itemsPerRow / 2, + limit = itemsPerRow * 2, // Fetch more since we'll filter enableTotalRecordCount = false, ) val seriesRequest = @@ -146,7 +170,7 @@ class RecommendedBoxSetViewModel userId = userId, type = listOf(BaseItemKind.SERIES), startIndex = 0, - limit = itemsPerRow / 2, + limit = itemsPerRow * 2, // Fetch more since we'll filter enableTotalRecordCount = false, ) @@ -154,14 +178,16 @@ class RecommendedBoxSetViewModel GetSuggestionsRequestHandler .execute(api, movieRequest) .toBaseItems(api, false) + .filter { it.id in boxSetItemIds } val series = GetSuggestionsRequestHandler .execute(api, seriesRequest) .toBaseItems(api, true) + .filter { it.id in boxSetItemIds } - Timber.d("BoxSet Suggestions - Movies: ${movies.size}, Series: ${series.size}") + Timber.d("BoxSet Suggestions (filtered) - Movies: ${movies.size}, Series: ${series.size}") val result = (movies + series).shuffled().take(itemsPerRow) - Timber.d("BoxSet Suggestions - Combined: ${result.size} items") + Timber.d("BoxSet Suggestions (filtered) - Combined: ${result.size} items") result }