From 6df89fe8c9293228109c736e4ae1e91a7b1e9d88 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 21 Dec 2025 19:00:59 -0500 Subject: [PATCH 1/9] Apply combined continue/next up, if enabled, on library recommended tabs (#533) ## Description Respect the combine Continue Watching & Next Up setting on TV Show library recommended tabs. This uses the same implementation on the home screen, but only includes items for that particular tv show library. ### Related issues Fixes #532 --- .../ui/components/RecommendedTvShow.kt | 78 +++++++++++++------ .../wholphin/ui/main/HomeViewModel.kt | 77 ++++++++++-------- 2 files changed, 96 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index cb179860..6e48692a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -9,14 +9,15 @@ 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.DatePlayedService 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.main.buildCombinedNextUp import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ExceptionHandler @@ -57,6 +58,7 @@ class RecommendedTvShowViewModel private val api: ApiClient, private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, + private val datePlayedService: DatePlayedService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, @@ -78,17 +80,17 @@ class RecommendedTvShowViewModel override fun init() { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { - val itemsPerRow = - preferencesDataStore.data - .firstOrNull() - ?.homePagePreferences - ?.maxItemsPerRow - ?: AppPreference.HomePageItems.defaultValue.toInt() + val preferences = + preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + val combineNextUp = preferences.homePagePreferences.combineContinueNext + val itemsPerRow = preferences.homePagePreferences.maxItemsPerRow + val userId = serverRepository.currentUser.value?.id try { val resumeItemsDeferred = viewModelScope.async(Dispatchers.IO) { val resumeItemsRequest = GetResumeItemsRequest( + userId = userId, parentId = parentId, fields = SlimItemFields, includeItemTypes = listOf(BaseItemKind.EPISODE), @@ -106,12 +108,14 @@ class RecommendedTvShowViewModel viewModelScope.async(Dispatchers.IO) { val nextUpRequest = GetNextUpRequest( - parentId = parentId, + userId = userId, fields = SlimItemFields, - enableUserData = true, - startIndex = 0, + imageTypeLimit = 1, + parentId = parentId, limit = itemsPerRow, - enableTotalRecordCount = false, + enableResumable = false, + enableUserData = true, + enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp, ) GetNextUpRequestHandler @@ -119,21 +123,45 @@ class RecommendedTvShowViewModel .toBaseItems(api, true) } val resumeItems = resumeItemsDeferred.await() - update( - R.string.continue_watching, - HomeRowLoadingState.Success( - context.getString(R.string.continue_watching), - resumeItems, - ), - ) val nextUpItems = nextUpItemsDeferred.await() - update( - R.string.next_up, - HomeRowLoadingState.Success( - context.getString(R.string.next_up), - nextUpItems, - ), - ) + if (combineNextUp) { + val combined = + buildCombinedNextUp( + viewModelScope, + datePlayedService, + resumeItems, + nextUpItems, + ) + update( + R.string.continue_watching, + HomeRowLoadingState.Success( + context.getString(R.string.continue_watching), + combined, + ), + ) + update( + R.string.next_up, + HomeRowLoadingState.Success( + context.getString(R.string.next_up), + listOf(), + ), + ) + } else { + update( + R.string.continue_watching, + HomeRowLoadingState.Success( + context.getString(R.string.continue_watching), + resumeItems, + ), + ) + update( + R.string.next_up, + HomeRowLoadingState.Success( + context.getString(R.string.next_up), + nextUpItems, + ), + ) + } if (resumeItems.isNotEmpty() || nextUpItems.isNotEmpty()) { loading.setValueOnMain(LoadingState.Success) 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 38c1ff4f..f9e1a7d1 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 @@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.supportItemKinds import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -109,7 +110,13 @@ class HomeViewModel val watching = buildList { if (prefs.combineContinueNext) { - val items = buildCombined(resume, nextUp) + val items = + buildCombinedNextUp( + viewModelScope, + datePlayedService, + resume, + nextUp, + ) add( HomeRowLoadingState.Success( title = context.getString(R.string.continue_watching), @@ -267,39 +274,6 @@ class HomeViewModel latestRows.setValueOnMain(rows) } - private suspend fun buildCombined( - resume: List, - nextUp: List, - ): List = - withContext(Dispatchers.IO) { - val start = System.currentTimeMillis() - val semaphore = Semaphore(3) - val deferred = - nextUp - .filter { it.data.seriesId != null } - .map { item -> - viewModelScope.async(Dispatchers.IO) { - try { - semaphore.withPermit { - datePlayedService.getLastPlayed(item) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching %s", item.id) - null - } - } - } - - val nextUpLastPlayed = deferred.awaitAll() - val timestamps = mutableMapOf() - nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) - resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } - val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } - val duration = (System.currentTimeMillis() - start).milliseconds - Timber.v("buildCombined took %s", duration) - return@withContext result - } - fun setWatched( itemId: UUID, played: Boolean, @@ -340,3 +314,38 @@ data class LatestData( val title: String, val request: GetLatestMediaRequest, ) + +suspend fun buildCombinedNextUp( + scope: CoroutineScope, + datePlayedService: DatePlayedService, + resume: List, + nextUp: List, +): List = + withContext(Dispatchers.IO) { + val start = System.currentTimeMillis() + val semaphore = Semaphore(3) + val deferred = + nextUp + .filter { it.data.seriesId != null } + .map { item -> + scope.async(Dispatchers.IO) { + try { + semaphore.withPermit { + datePlayedService.getLastPlayed(item) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching %s", item.id) + null + } + } + } + + val nextUpLastPlayed = deferred.awaitAll() + val timestamps = mutableMapOf() + nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) + resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } + val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } + val duration = (System.currentTimeMillis() - start).milliseconds + Timber.v("buildCombined took %s", duration) + return@withContext result + } From 925a4b9d82145834e632f0888d6d4c5b1793d717 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 21 Dec 2025 21:30:00 -0500 Subject: [PATCH 2/9] Add multi-part videos to playback queue (#520) ## Description When playing media with multiple parts, the additional parts will be added to the playback queue. This means a multi-part movie or episode will be play all of the parts in order before the next item in the queue. This only affects the playback queue. There's no UI changes and no indication that a movie or episode has multiple parts. I'll work on that in a separate PR. ### Related issues Related to #455 --- .../wholphin/services/PlaylistCreator.kt | 70 +++++++++++++++---- .../damontecres/wholphin/ui/Extensions.kt | 7 ++ .../wholphin/ui/playback/PlaybackViewModel.kt | 11 +-- 3 files changed, 68 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index 4d15d414..824342d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.model.PlaylistInfo import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.components.baseItemKinds import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.toServerString @@ -22,6 +23,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playlistsApi +import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CreatePlaylistDto @@ -64,10 +66,14 @@ class PlaylistCreator sortBy = if (shuffled) ItemSortBy.RANDOM else null, limit = Playlist.MAX_SIZE, ) - val episodes = GetEpisodesRequestHandler.execute(api, request).content.items + val episodes = + GetEpisodesRequestHandler + .execute(api, request) + .content.items + .convertAndAddParts(false) val startIndex = episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0 - return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex) + return Playlist(episodes, startIndex) } suspend fun createFromPlaylistId( @@ -82,12 +88,11 @@ class PlaylistCreator startIndex = startIndex, limit = Playlist.MAX_SIZE, ) - val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items - var baseItems = items.map { BaseItem.from(it, api) } + var items = GetPlaylistItemsRequestHandler.execute(api, request).content.items if (shuffled) { - baseItems = baseItems.shuffled() + items = items.shuffled() } - return Playlist(baseItems, 0) + return Playlist(items.convertAndAddParts(), 0) } private suspend fun createFromCollection( @@ -120,19 +125,20 @@ class PlaylistCreator ), ) val items = - GetItemsRequestHandler.execute(api, request).content.items.map { - BaseItem.from(it, api) - } + GetItemsRequestHandler + .execute(api, request) + .content.items + .convertAndAddParts() return Playlist(items, 0) } suspend fun createFrom( item: BaseItemDto, startIndex: Int = 0, - sortAndDirection: SortAndDirection?, - shuffled: Boolean, - recursive: Boolean, - filter: GetItemsFilter, + sortAndDirection: SortAndDirection? = SortAndDirection.DEFAULT, + shuffled: Boolean = false, + recursive: Boolean = false, + filter: GetItemsFilter = GetItemsFilter(), ): PlaylistCreationResult = when (item.type) { BaseItemKind.BOX_SET, @@ -166,7 +172,7 @@ class PlaylistCreator ), ) } else { - PlaylistCreationResult.Error(null, "Episode has not seriesId") + PlaylistCreationResult.Error(null, "Episode has no seriesId") } } @@ -182,7 +188,7 @@ class PlaylistCreator ), ) } else { - PlaylistCreationResult.Error(null, "Episode has not seriesId") + PlaylistCreationResult.Error(null, "Episode has no seriesId") } } @@ -207,6 +213,26 @@ class PlaylistCreator ) } + BaseItemKind.MOVIE, + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + -> { + val list = + buildList { + add(BaseItem(item, false)) + + if (item.partCount.gt(1)) { + api.videosApi + .getAdditionalPart(item.id) + .content.items + .map { + BaseItem(it, false) + }.let(::addAll) + } + } + PlaylistCreationResult.Success(Playlist(list, 0)) + } + // Not support yet // BaseItemKind.AGGREGATE_FOLDER -> TODO() // BaseItemKind.FOLDER -> TODO() @@ -220,6 +246,20 @@ class PlaylistCreator } } + private suspend fun List.convertAndAddParts(useSeriesForPrimary: Boolean = false): List = + buildList { + this@convertAndAddParts.forEach { ep -> + add(BaseItem(ep, useSeriesForPrimary)) + if (ep.partCount.gt(1)) { + val parts = + api.videosApi.getAdditionalPart(ep.id).content.items.map { part -> + BaseItem(part, useSeriesForPrimary) + } + addAll(parts) + } + } + } + suspend fun getServerPlaylists( mediaType: MediaType?, scope: CoroutineScope, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index 93ad9946..bc39c48e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -418,3 +418,10 @@ fun rememberBackDropImage(item: BaseItem): String? { val imageUrlService = LocalImageUrlService.current return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } } + +/** + * Check if this, coalescing nulls to zero, is greater than that + */ +fun Int?.gt(that: Int) = (this ?: 0) > that + +fun Int?.lt(that: Int) = (this ?: 0) < that diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index e462a312..1d380e08 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -286,11 +286,12 @@ class PlaybackViewModel playNextUp() } - if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) { - val playlist = - playlistCreator.createFromEpisode(queriedItem.seriesId!!, queriedItem.id) - withContext(Dispatchers.Main) { - this@PlaybackViewModel.playlist.value = playlist + if (!isPlaylist) { + val result = playlistCreator.createFrom(queriedItem) + if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { + withContext(Dispatchers.Main) { + this@PlaybackViewModel.playlist.value = result.playlist + } } } } From 60838c2a863b13ae5d225b32c3dfc33ba5a4a6c3 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 21 Dec 2025 21:31:01 -0500 Subject: [PATCH 3/9] Fixes a few bugs (#535) ## Description A few minor bug fixes across the app - Fix the language not showing after search on the subtitle search & download dialog - Clear the backdrop whenever the home page is reloading from scratch - Enable pressing a remote's play (or play/pause) button on the series page to play the focused episode ### Related issues Play button follow up from #424 --- .../wholphin/ui/detail/series/SeriesOverviewContent.kt | 8 ++++++++ .../github/damontecres/wholphin/ui/main/HomeViewModel.kt | 1 + .../wholphin/ui/playback/DownloadSubtitlesDialog.kt | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index fa225688..9a2d4dcf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -59,6 +60,7 @@ import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.logTab +import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll import kotlinx.coroutines.launch @@ -249,6 +251,12 @@ fun SeriesOverviewContent( bringIntoViewRequester.bringIntoView() } } + }.onKeyEvent { + if (episode != null && isPlayKeyUp(it)) { + onClick.invoke(episode) + return@onKeyEvent true + } + return@onKeyEvent false }, interactionSource = interactionSource, cardHeight = 120.dp, 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 f9e1a7d1..ad1c2970 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 @@ -91,6 +91,7 @@ class HomeViewModel ), ) { Timber.d("init HomeViewModel") + backdropService.clearBackdrop() serverRepository.currentUserDto.value?.let { userDto -> val includedIds = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt index e145d380..b3813ed5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt @@ -107,7 +107,7 @@ fun DownloadSubtitlesContent( color = MaterialTheme.colorScheme.onSurface, ) // val lang = rememberTextFieldState(language) - var lang by rememberSaveable { mutableStateOf("") } + var lang by rememberSaveable { mutableStateOf(language) } Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, From 20fe5e262684a394683ccc0588d62de003a7fcd5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 22 Dec 2025 10:40:42 -0500 Subject: [PATCH 4/9] Move MPV init & destroy to looper thread (#534) ## Description No user facing changes in this PR This is a continuation of #481 to move initializing and tearing down `libmpv` onto the looper thread to prevent main thread blocking. ~The only function left that runs on the main thread is `setVideoSurfaceView`. Since this has to directly interact with the UI, I need to read the docs and ExoPlayer code a bit more to see how this can safely done multi-threaded.~ It is safe/correct to attach on another thread. --- .../damontecres/wholphin/util/mpv/MPVLib.kt | 12 ++- .../wholphin/util/mpv/MpvPlayer.kt | 73 +++++++++++++------ 2 files changed, 59 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt index 52af2644..acb2f8d8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt @@ -38,9 +38,17 @@ object MPVLib { external fun create(appctx: Context) - external fun init() + fun initialize() { + synchronized(this) { init() } + } - external fun destroy() + private external fun init() + + fun tearDown() { + synchronized(this) { destroy() } + } + + private external fun destroy() external fun attachSurface(surface: Surface) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt index a31638c4..4777e977 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt @@ -76,6 +76,7 @@ class MpvPlayer( private var surface: Surface? = null private val playbackState = AtomicReference(PlaybackState.EMPTY) + private val mpvLogger = MpvLogger() // This looper will sent events to the main thread private val looper = Util.getCurrentOrMainLooper() @@ -87,7 +88,7 @@ class MpvPlayer( ) { listener, eventFlags -> listener.onEvents(this@MpvPlayer, Player.Events(eventFlags)) } - private val availableCommands: Player.Commands + private var availableCommands: Player.Commands = Player.Commands.Builder().build() private val trackSelector = DefaultTrackSelector(context) // This thread/looper will receive commands from the main thread to execute @@ -101,7 +102,13 @@ class MpvPlayer( private set init { + sendCommand(MpvCommand.INITIALIZE, null) + } + + private fun init() { Timber.v("config-dir=${context.filesDir.path}") + MPVLib.addLogObserver(mpvLogger) + MPVLib.create(context) MPVLib.setOptionString("config", "yes") MPVLib.setOptionString("config-dir", context.filesDir.path) @@ -120,17 +127,14 @@ class MpvPlayer( MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}") MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") - MPVLib.init() + MPVLib.initialize() MPVLib.setOptionString("force-window", "no") MPVLib.setOptionString("idle", "yes") // MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath) - internalHandler.post { - MPVLib.addObserver(this@MpvPlayer) - MPVProperty.observedProperties.forEach(MPVLib::observeProperty) - MPVLib.addLogObserver(MpvLogger()) - } + MPVLib.addObserver(this@MpvPlayer) + MPVProperty.observedProperties.forEach(MPVLib::observeProperty) availableCommands = Player.Commands @@ -298,15 +302,14 @@ class MpvPlayer( override fun release() { Timber.i("release") - internalHandler.removeCallbacks(updatePlaybackState) playbackState.update { PlaybackState.EMPTY } if (!isReleased) { - thread.quit() - MPVLib.removeObserver(this) - clearVideoSurfaceView(null) - MPVLib.destroy() + internalHandler.removeCallbacks(updatePlaybackState) + MPVLib.removeObserver(this@MpvPlayer) + sendCommand(MpvCommand.DESTROY, null) + thread.quitSafely() } isReleased = true } @@ -466,15 +469,9 @@ class MpvPlayer( throwIfReleased() if (DEBUG) Timber.v("setVideoSurfaceView") val surface = surfaceView?.holder?.surface - if (surface != null) { - this.surface = surface + if (surface != null && surface.isValid) { Timber.v("Queued attach") - MPVLib.attachSurface(surface) - MPVLib.setOptionString("force-window", "yes") - Timber.d("Attached surface") - playbackState.load().media?.let { - sendCommand(MpvCommand.LOAD_FILE, it) - } + sendCommand(MpvCommand.ATTACH_SURFACE, surface) } else { clearVideoSurfaceView(null) } @@ -483,9 +480,7 @@ class MpvPlayer( override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { if (surface == surfaceView?.holder?.surface) { Timber.d("clearVideoSurfaceView") - MPVLib.detachSurface() - MPVLib.setPropertyString("vo", "null") - MPVLib.setPropertyString("force-window", "no") + sendCommand(MpvCommand.ATTACH_SURFACE, null) } else { Timber.w("clearVideoSurfaceView called with different surface") } @@ -621,7 +616,7 @@ class MpvPlayer( } override fun event(eventId: Int) { - Timber.v("event: thread=${Thread.currentThread().name}, eventId=$eventId") + Timber.v("event: eventId=%s", eventId) when (eventId) { MPV_EVENT_START_FILE -> { internalHandler.post(updatePlaybackState) @@ -891,6 +886,33 @@ class MpvPlayer( MpvCommand.LOAD_FILE -> { loadFile(msg.obj as MediaAndPosition) } + + MpvCommand.ATTACH_SURFACE -> { + val surface = msg.obj as Surface? + if (surface == null || (this.surface != null && this.surface != surface)) { + // If clearing or changing the surface + MPVLib.detachSurface() + MPVLib.setPropertyString("vo", "null") + MPVLib.setPropertyString("force-window", "no") + } + if (surface != null) { + MPVLib.attachSurface(surface) + this.surface = surface + MPVLib.setOptionString("force-window", "yes") + Timber.d("Attached surface") + } + } + + MpvCommand.INITIALIZE -> { + init() + } + + MpvCommand.DESTROY -> { + clearVideoSurfaceView(null) + MPVLib.removeLogObserver(mpvLogger) + MPVLib.tearDown() + Timber.d("MPVLib destroyed") + } } return true } @@ -1020,4 +1042,7 @@ enum class MpvCommand { SET_SPEED, SET_SUBTITLE_DELAY, LOAD_FILE, + ATTACH_SURFACE, + INITIALIZE, + DESTROY, } From eb4e9b93beb0c92a98e9f5d2440964078b34bf93 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:52:09 -0500 Subject: [PATCH 5/9] Add setting to toggle automatic sign in (#538) ## Description The major user facing change in this PR is adding back the setting to toggle automatic sign in on or off. This controls whether to restore the app to where you left off or go the user selection page. If enabled: re-opening the app will restore the previous user's session If disabled: re-opening the app will always start with the user selection page for the most recent server, or if there isn't a recent server, the server selection page This also separate this setting from the PIN protection settings in previous PRs. ### Dev changes This PR refactors the "setup" UI (server & user selection) to be separate from the "app" UI (the actually app content). There is now a `NavDisplay` for the setup flow which ends with displaying the `ApplicationContent`'s `NavDisplay`. Most of the logic is moved out of Compose code and into a `ViewModel`. This means to switch users/server, the `SetupNavigationManager` must be used instead of `NavigationManager`. ### Related issues Closes #396 Fixes #376 --- .../damontecres/wholphin/MainActivity.kt | 251 ++++++++++-------- .../wholphin/data/ServerRepository.kt | 92 ++++--- .../wholphin/data/model/JellyfinServer.kt | 1 + .../wholphin/preferences/AppPreference.kt | 3 +- .../wholphin/services/DeviceProfileService.kt | 2 +- .../wholphin/services/NavigationManager.kt | 7 +- .../services/SetupNavigationManager.kt | 54 ++++ .../wholphin/ui/nav/ApplicationContent.kt | 18 +- .../wholphin/ui/nav/Destination.kt | 9 - .../wholphin/ui/nav/DestinationContent.kt | 10 - .../damontecres/wholphin/ui/nav/NavDrawer.kt | 9 +- .../ui/setup/SwitchServerViewModel.kt | 10 +- .../wholphin/ui/setup/SwitchUserContent.kt | 5 +- .../wholphin/ui/setup/SwitchUserViewModel.kt | 29 +- 14 files changed, 302 insertions(+), 198 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 52a63020..22e41a49 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin import android.os.Bundle import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -12,18 +13,18 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope -import androidx.navigation3.runtime.rememberNavBackStack +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface @@ -33,24 +34,30 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.AppUpgradeHandler +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.RefreshRateService -import com.github.damontecres.wholphin.services.ServerEventListener +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ApplicationContent -import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setup.SwitchServerContent +import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -59,8 +66,7 @@ import javax.inject.Inject @AndroidEntryPoint class MainActivity : AppCompatActivity() { - @Inject - lateinit var serverRepository: ServerRepository + private val viewModel: MainActivityViewModel by viewModels() @Inject lateinit var userPreferencesDataStore: DataStore @@ -72,27 +78,26 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var navigationManager: NavigationManager + @Inject + lateinit var setupNavigationManager: SetupNavigationManager + @Inject lateinit var updateChecker: UpdateChecker @Inject lateinit var appUpgradeHandler: AppUpgradeHandler - @Inject - lateinit var serverEventListener: ServerEventListener - @Inject lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver - @Inject - lateinit var deviceProfileService: DeviceProfileService - @Inject lateinit var imageUrlService: ImageUrlService @Inject lateinit var refreshRateService: RefreshRateService + private var signInAuto = true + @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -109,9 +114,13 @@ class MainActivity : AppCompatActivity() { window.attributes = attrs.apply { preferredRefreshRate = mode.refreshRate } } } + viewModel.appStart() setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) appPreferences?.let { appPreferences -> + LaunchedEffect(appPreferences.signInAutomatically) { + signInAuto = appPreferences.signInAutomatically + } CoilConfig( diskCacheSizeBytes = appPreferences.advancedPreferences.imageDiskCacheSizeBytes.let { @@ -140,100 +149,71 @@ class MainActivity : AppCompatActivity() { .background(MaterialTheme.colorScheme.background), shape = RectangleShape, ) { - var isRestoringSession by remember { mutableStateOf(true) } - LaunchedEffect(Unit) { - // TODO PIN-related -// if (appPreferences.signInAutomatically) { - try { - serverRepository.restoreSession( - appPreferences.currentServerId?.toUUIDOrNull(), - appPreferences.currentUserId?.toUUIDOrNull(), - ) - } catch (ex: Exception) { - Timber.e(ex, "Exception restoring session") - } -// } - isRestoringSession = false - } - val current by serverRepository.current.observeAsState() +// val backStack = rememberNavBackStack(SetupDestination.Loading) +// setupNavigationManager.backStack = backStack + val backStack = setupNavigationManager.backStack + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = { key -> + key as SetupDestination + NavEntry(key) { + when (key) { + SetupDestination.Loading -> { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } - val preferences = - UserPreferences( - appPreferences, - current?.userDto?.configuration ?: DefaultUserConfiguration, - ) + SetupDestination.ServerList -> { + SwitchServerContent(Modifier.fillMaxSize()) + } - if (isRestoringSession) { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } else { - // TODO PIN-related -// DisposableEffect(Unit) { -// onDispose { -// if (!appPreferences.signInAutomatically || current?.user?.hasPin == true) { -// serverRepository.closeSession() -// } -// } -// } - key(current?.server?.id, current?.user?.id) { - // TODO PIN-related -// LaunchedEffect(current?.user?.pin) { -// if (current?.user?.pin?.isNotNullOrBlank() == true) { -// // If user has a pin, then obscure the window in previews -// window?.setFlags( -// WindowManager.LayoutParams.FLAG_SECURE, -// WindowManager.LayoutParams.FLAG_SECURE, -// ) -// } else { -// window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) -// } -// } - val initialDestination = - when { - current != null -> Destination.Home() + is SetupDestination.UserList -> { + SwitchUserContent( + currentServer = key.server, + Modifier.fillMaxSize(), + ) + } - // TODO PIN-related - // !appPreferences.signInAutomatically -> Destination.ServerList - - else -> Destination.ServerList - } - val backStack = rememberNavBackStack(initialDestination) - navigationManager.backStack = backStack - if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { - LaunchedEffect(Unit) { - try { - updateChecker.maybeShowUpdateToast(appPreferences.updateUrl) - } catch (ex: Exception) { - Timber.w(ex, "Failed to check for update") + is SetupDestination.AppContent -> { + val current = key.current + val preferences = + UserPreferences( + appPreferences, + current.userDto.configuration + ?: DefaultUserConfiguration, + ) + ProvideLocalClock { + LaunchedEffect(Unit) { + updateChecker.maybeShowUpdateToast( + appPreferences.updateUrl, + ) + } + ApplicationContent( + user = current.user, + server = current.server, + navigationManager = navigationManager, + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) + } } } } - LaunchedEffect(current, preferences) { - withContext(Dispatchers.IO) { - deviceProfileService.getOrCreateDeviceProfile( - preferences.appPreferences.playbackPreferences, - current?.server?.serverVersion, - ) - } - } - ProvideLocalClock { - ApplicationContent( - user = current?.user, - server = current?.server, - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), - ) - } - } - } + }, + ) } } } @@ -250,12 +230,69 @@ class MainActivity : AppCompatActivity() { override fun onRestart() { super.onRestart() - // TODO PIN-related + Timber.i("onRestart") + viewModel.appStart() // val signInAutomatically = // runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true -// Timber.i("onRestart: signInAutomatically=$signInAutomatically") -// if (!signInAutomatically || serverRepository.currentUser.value?.hasPin == true) { + +// // TODO PIN-related +// // if (!signInAutomatically || serverRepository.currentUser.value?.hasPin == true) { +// if (!signInAutomatically) { // serverRepository.closeSession() // } } } + +@HiltViewModel +class MainActivityViewModel + @Inject + constructor( + private val preferences: DataStore, + private val serverRepository: ServerRepository, + private val navigationManager: SetupNavigationManager, + private val deviceProfileService: DeviceProfileService, + private val backdropService: BackdropService, + ) : ViewModel() { + fun appStart() { + viewModelScope.launch { + val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + if (prefs.signInAutomatically) { + val current = + withContext(Dispatchers.IO) { + serverRepository.restoreSession( + prefs.currentServerId?.toUUIDOrNull(), + prefs.currentUserId?.toUUIDOrNull(), + ) + } + if (current != null) { + // Restored + navigationManager.navigateTo(SetupDestination.AppContent(current)) + } else { + // Did not restore + navigationManager.navigateTo(SetupDestination.ServerList) + } + } else { + navigationManager.navigateTo(SetupDestination.Loading) + backdropService.clearBackdrop() + val currentServerId = prefs.currentServerId?.toUUIDOrNull() + if (currentServerId != null) { + val currentServer = + withContext(Dispatchers.IO) { + serverRepository.serverDao.getServer(currentServerId)?.server + } + if (currentServer != null) { + navigationManager.navigateTo(SetupDestination.UserList(currentServer)) + } else { + navigationManager.navigateTo(SetupDestination.ServerList) + } + } else { + navigationManager.navigateTo(SetupDestination.ServerList) + } + } + } + viewModelScope.launchIO { + // Create the mediaCodecCapabilitiesTest if needed + deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC() + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 48bea5b3..539c8ac4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.util.EqualityMutableLiveData import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.systemApi @@ -68,44 +69,46 @@ class ServerRepository suspend fun changeUser( server: JellyfinServer, user: JellyfinUser, - ) = withContext(Dispatchers.IO) { - if (server.id != user.serverId) { - throw IllegalStateException("User is not part of the server") - } - Timber.v("Changing user to ${user.name} on ${server.url}") - apiClient.update(baseUrl = server.url, accessToken = user.accessToken) - val userDto by apiClient.userApi.getCurrentUser() - val updatedServer = - try { - val sysInfo by apiClient.systemApi.getPublicSystemInfo() - server.copy(name = sysInfo.serverName, version = sysInfo.version) - } catch (ex: Exception) { - Timber.w(ex, "Exception fetching public system info") - server + ): CurrentUser? = + withContext(Dispatchers.IO) { + if (server.id != user.serverId) { + throw IllegalStateException("User is not part of the server") } - var updatedUser = - user.copy( - id = userDto.id, - name = userDto.name, - ) - serverDao.addOrUpdateServer(updatedServer) - updatedUser = serverDao.addOrUpdateUser(updatedUser) - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = updatedServer.id.toServerString() - currentUserId = updatedUser.id.toServerString() - }.build() + Timber.v("Changing user to ${user.name} on ${server.url}") + apiClient.update(baseUrl = server.url, accessToken = user.accessToken) + val userDto by apiClient.userApi.getCurrentUser() + val updatedServer = + try { + val sysInfo by apiClient.systemApi.getPublicSystemInfo() + server.copy(name = sysInfo.serverName, version = sysInfo.version) + } catch (ex: Exception) { + Timber.w(ex, "Exception fetching public system info") + server + } + var updatedUser = + user.copy( + id = userDto.id, + name = userDto.name, + ) + serverDao.addOrUpdateServer(updatedServer) + updatedUser = serverDao.addOrUpdateUser(updatedUser) + userPreferencesDataStore.updateData { + it + .toBuilder() + .apply { + currentServerId = updatedServer.id.toServerString() + currentUserId = updatedUser.id.toServerString() + }.build() + } + withContext(Dispatchers.Main) { + _current.value = CurrentUser(updatedServer, updatedUser, userDto) + } + sharedPreferences.edit(true) { + putString(SERVER_URL_KEY, updatedServer.url) + putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) + } + return@withContext _current.value } - withContext(Dispatchers.Main) { - _current.value = CurrentUser(updatedServer, updatedUser, userDto) - } - sharedPreferences.edit(true) { - putString(SERVER_URL_KEY, updatedServer.url) - putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) - } - } /** * Restores a session for the given server & user such as when the app reopens @@ -115,10 +118,10 @@ class ServerRepository suspend fun restoreSession( serverId: UUID?, userId: UUID?, - ): Boolean { + ): CurrentUser? { if (serverId == null || userId == null) { _current.setValueOnMain(null) - return false + return null } val serverAndUsers = withContext(Dispatchers.IO) { @@ -129,13 +132,17 @@ class ServerRepository if (user != null) { // TODO pin-related // if (user != null && !user.hasPin) { - changeUser(serverAndUsers.server, user) - return true + return changeUser(serverAndUsers.server, user) } } - return false + return null } + suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? = + withContext(Dispatchers.IO) { + serverId?.let { serverDao.getServer(serverId)?.server } + } + fun closeSession() { _current.value = null } @@ -170,7 +177,7 @@ class ServerRepository ) } if (user != null) { - changeUser(server, user) + return@withContext changeUser(server, user) } else { throw IllegalArgumentException("Authentication result's user was null") } @@ -260,6 +267,7 @@ class ServerRepository } } +@Serializable data class CurrentUser( val server: JellyfinServer, val user: JellyfinUser, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index d8e40779..2c516272 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -41,6 +41,7 @@ data class JellyfinServer( ], indices = [Index("id", "serverId", unique = true)], ) +@Serializable data class JellyfinUser( @PrimaryKey(autoGenerate = true) val rowId: Int = 0, 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 4d6154d6..6bca9d38 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 @@ -851,8 +851,7 @@ val basicPreferences = title = R.string.ui_interface, preferences = listOf( - // TODO PIN-related - // AppPreference.SignInAuto, + AppPreference.SignInAuto, AppPreference.HomePageItems, AppPreference.CombineContinueNext, AppPreference.RewatchNextUp, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index ed04c3f2..9ce70b21 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -20,7 +20,7 @@ class DeviceProfileService constructor( @param:ApplicationContext private val context: Context, ) { - private val mediaCodecCapabilitiesTest by lazy { + val mediaCodecCapabilitiesTest by lazy { // Created lazily below on the IO thread since it cn take time MediaCodecCapabilitiesTest(context) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index a387ac8c..136cb746 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -29,12 +29,7 @@ class NavigationManager */ fun navigateToFromDrawer(destination: Destination) { goToHome() - if (destination == Destination.ServerList || destination is Destination.UserList) { - backStack.add(destination) - (0.. backStack.removeAt(0) } - } else { - backStack.add(destination) - } + backStack.add(destination) log() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt new file mode 100644 index 00000000..bd95ea1d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt @@ -0,0 +1,54 @@ +package com.github.damontecres.wholphin.services + +import androidx.compose.runtime.mutableStateListOf +import androidx.navigation3.runtime.NavKey +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.model.JellyfinServer +import kotlinx.serialization.Serializable +import org.acra.ACRA +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages navigating for setup + */ +@Singleton +class SetupNavigationManager + @Inject + constructor() { + var backStack: MutableList = mutableStateListOf(SetupDestination.Loading) + + /** + * Go to the specified [SetupDestination] + */ + fun navigateTo(destination: SetupDestination) { + backStack[0] = destination + log() + } + + private fun log() { + val dest = backStack.lastOrNull().toString() + Timber.i("Current setup destination: %s", dest) + ACRA.errorReporter.putCustomData("setupDestination", dest) + } + } + +@Serializable +sealed interface SetupDestination : NavKey { + @Serializable + data object Loading : SetupDestination + + @Serializable + data object ServerList : SetupDestination + + @Serializable + data class UserList( + val server: JellyfinServer, + ) : SetupDestination + + @Serializable + data class AppContent( + val current: CurrentUser, + ) : SetupDestination +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 2d3200ba..7e7d0d2b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -24,8 +25,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.runtime.serialization.NavBackStackSerializer +import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.MaterialTheme import coil3.compose.AsyncImage @@ -62,13 +67,22 @@ class ApplicationContentViewModel */ @Composable fun ApplicationContent( - server: JellyfinServer?, - user: JellyfinUser?, + server: JellyfinServer, + user: JellyfinUser, navigationManager: NavigationManager, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: ApplicationContentViewModel = hiltViewModel(), ) { + val backStack: MutableList = + rememberSerializable( + server, + user, + serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()), + ) { + NavBackStack(Destination.Home()) + } + navigationManager.backStack = backStack val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle Box( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 9310d0fa..b9452daa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -7,7 +7,6 @@ import androidx.navigation3.runtime.NavKey import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds @@ -28,14 +27,6 @@ import java.util.UUID sealed class Destination( val fullScreen: Boolean = false, ) : NavKey { - @Serializable - data object ServerList : Destination(true) - - @Serializable - data class UserList( - val server: JellyfinServer, - ) : Destination(true) - @Serializable data class Home( val id: Long = 0L, 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 2f6feb7a..038f231a 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 @@ -29,8 +29,6 @@ import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage -import com.github.damontecres.wholphin.ui.setup.SwitchServerContent -import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import timber.log.Timber @@ -66,14 +64,6 @@ fun DestinationContent( ) } - Destination.ServerList -> { - SwitchServerContent(modifier) - } - - is Destination.UserList -> { - SwitchUserContent(destination.server, modifier) - } - is Destination.Settings -> { PreferencesPage( preferences.appPreferences, 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 9a7d3559..ef858eb7 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 @@ -79,6 +79,8 @@ import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService 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.components.TimeDisplay import com.github.damontecres.wholphin.ui.ifElse @@ -106,6 +108,7 @@ class NavDrawerViewModel constructor( private val navDrawerItemRepository: NavDrawerItemRepository, val navigationManager: NavigationManager, + val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, ) : ViewModel() { private var all: List? = null @@ -361,10 +364,8 @@ fun NavDrawer( drawerOpen = drawerState.isOpen, interactionSource = interactionSource, onClick = { - viewModel.navigationManager.navigateToFromDrawer( - Destination.UserList( - server, - ), + viewModel.setupNavigationManager.navigateTo( + SetupDestination.UserList(server), ) }, modifier = Modifier.padding(start = drawerPadding), 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 75af5155..f9e7d0a3 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,9 +9,9 @@ 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.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState @@ -41,7 +41,7 @@ class SwitchServerViewModel val jellyfin: Jellyfin, val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, - val navigationManager: NavigationManager, + val navigationManager: SetupNavigationManager, ) : ViewModel() { val servers = MutableLiveData>(listOf()) val serverStatus = MutableLiveData>(mapOf()) @@ -156,7 +156,7 @@ class SwitchServerViewModel ) serverRepository.addAndChangeServer(updatedServer) withContext(Dispatchers.Main) { - navigationManager.navigateTo(Destination.UserList(server)) + navigationManager.navigateTo(SetupDestination.UserList(updatedServer)) } } else if (result is ServerConnectionStatus.Error) { showToast(context, "Error connecting: $${result.message}") @@ -201,7 +201,7 @@ class SwitchServerViewModel } withContext(Dispatchers.Main) { addServerState.value = LoadingState.Success - navigationManager.navigateTo(Destination.UserList(server)) + navigationManager.navigateTo(SetupDestination.UserList(server)) } } else { withContext(Dispatchers.Main) { 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 19c00f18..b71e0af0 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 @@ -37,6 +37,7 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.EditTextBox @@ -130,7 +131,9 @@ fun SwitchUserContent( viewModel.removeUser(user) }, onSwitchServer = { - viewModel.navigationManager.navigateTo(Destination.ServerList) + viewModel.setupNavigationManager.navigateTo( + SetupDestination.ServerList, + ) }, modifier = Modifier.fillMaxWidth(), ) 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 7da64ca7..68b9e583 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 @@ -9,6 +9,8 @@ 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.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler @@ -43,6 +45,7 @@ class SwitchUserViewModel val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, val navigationManager: NavigationManager, + val setupNavigationManager: SetupNavigationManager, val imageUrlService: ImageUrlService, @Assisted val server: JellyfinServer, ) : ViewModel() { @@ -120,9 +123,11 @@ class SwitchUserViewModel fun switchUser(user: JellyfinUser) { viewModelScope.launchIO { try { - serverRepository.changeUser(server, user) - withContext(Dispatchers.Main) { - navigationManager.goToHome() + val current = serverRepository.changeUser(server, user) + if (current != null) { + withContext(Dispatchers.Main) { + setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) + } } } catch (ex: Exception) { Timber.e(ex, "Error switching user") @@ -144,9 +149,11 @@ class SwitchUserViewModel username = username, password = password, ) - serverRepository.changeUser(server.url, authenticationResult) - withContext(Dispatchers.Main) { - navigationManager.goToHome() + val current = serverRepository.changeUser(server.url, authenticationResult) + if (current != null) { + withContext(Dispatchers.Main) { + setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) + } } } catch (ex: Exception) { Timber.e(ex, "Error logging in user") @@ -192,9 +199,13 @@ class SwitchUserViewModel val authenticationResult by api.userApi.authenticateWithQuickConnect( QuickConnectDto(secret = state.secret), ) - serverRepository.changeUser(server.url, authenticationResult) - withContext(Dispatchers.Main) { - navigationManager.goToHome() + val current = serverRepository.changeUser(server.url, authenticationResult) + if (current != null) { + withContext(Dispatchers.Main) { + setupNavigationManager.navigateTo( + SetupDestination.AppContent(current), + ) + } } } catch (ex: Exception) { Timber.e(ex, "Error during quick connect") From af54e777dc9079bdf7d9e69c83df3664a3409732 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:28:21 -0500 Subject: [PATCH 6/9] Fix settings changes requiring an app restart (#539) ## Description - Ensures settings changes take effect immediately instead of requiring an app restart - Add conditional & catch when checking for app updates ### Related issues Fixes issues introduced in #538 --- .../damontecres/wholphin/MainActivity.kt | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 22e41a49..41d24b3c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape @@ -189,18 +190,32 @@ class MainActivity : AppCompatActivity() { is SetupDestination.AppContent -> { val current = key.current - val preferences = - UserPreferences( - appPreferences, - current.userDto.configuration - ?: DefaultUserConfiguration, - ) ProvideLocalClock { - LaunchedEffect(Unit) { - updateChecker.maybeShowUpdateToast( - appPreferences.updateUrl, - ) + if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { + LaunchedEffect(Unit) { + try { + updateChecker.maybeShowUpdateToast( + appPreferences.updateUrl, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Exception during update check", + ) + } + } } + val appPreferences by userPreferencesDataStore.data.collectAsState( + appPreferences, + ) + val preferences = + remember(appPreferences, current) { + UserPreferences( + appPreferences, + current.userDto.configuration + ?: DefaultUserConfiguration, + ) + } ApplicationContent( user = current.user, server = current.server, From 9a08cf3f25e8d6bf6d5e19603121fb6bb74ae3d2 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:44:08 -0500 Subject: [PATCH 7/9] Don't use saved library info for genre grids (#540) ## Description The filtering & sorting for the genre results grid is now independent from the actual library. This prevents having a filter saved on the actual library that conflicts with the Genre grid. Also shows the genre name in the title of the page ### Related issues Fixes #536 --- .../wholphin/data/model/GetItemsFilter.kt | 10 +++ .../wholphin/ui/cards/GenreCard.kt | 1 - .../ui/components/CollectionFolderGrid.kt | 65 +++++++++++-------- .../wholphin/ui/components/GenreCardGrid.kt | 25 ++++++- .../ui/detail/CollectionFolderBoxSet.kt | 4 +- .../ui/detail/CollectionFolderGeneric.kt | 4 +- .../ui/detail/CollectionFolderLiveTv.kt | 4 +- .../ui/detail/CollectionFolderMovie.kt | 15 +++-- .../ui/detail/CollectionFolderPlaylist.kt | 4 +- .../ui/detail/CollectionFolderRecordings.kt | 4 +- .../wholphin/ui/detail/CollectionFolderTv.kt | 8 ++- .../wholphin/ui/detail/FavoritesPage.kt | 55 +++++++++++----- .../wholphin/ui/nav/Destination.kt | 3 +- 13 files changed, 139 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt index bb733ccd..f819c7c3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt @@ -14,6 +14,16 @@ import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID +@Serializable +data class CollectionFolderFilter( + val nameOverride: String? = null, + val filter: GetItemsFilter = GetItemsFilter(), + /** + * Whether to use the libray's saved sort & filter + */ + val useSavedLibraryDisplayInfo: Boolean = true, +) + @Serializable data class GetItemsFilter( val favorite: Boolean? = null, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt index 43d160e3..c2e863c3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -65,7 +65,6 @@ fun GenreCard( .fillMaxSize() .clip(RoundedCornerShape(8.dp)), ) { - Timber.v("genre image=${genre?.imageUrl}") if (genre?.imageUrl.isNotNullOrBlank()) { AsyncImage( model = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index f52f3970..d7dc7cae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -58,6 +58,7 @@ import com.github.damontecres.wholphin.data.filter.PlayedFilter import com.github.damontecres.wholphin.data.filter.VideoTypeFilter import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo @@ -136,12 +137,13 @@ class CollectionFolderViewModel val viewOptions = MutableLiveData() private var useSeriesForPrimary: Boolean = true + private lateinit var collectionFilter: CollectionFolderFilter fun init( itemId: String, initialSortAndDirection: SortAndDirection?, recursive: Boolean, - filter: GetItemsFilter, + collectionFilter: CollectionFolderFilter, useSeriesForPrimary: Boolean, defaultViewOptions: ViewOptions, ): Job = @@ -151,6 +153,7 @@ class CollectionFolderViewModel context.getString(R.string.error_loading_collection, itemId), ) + Dispatchers.IO, ) { + this@CollectionFolderViewModel.collectionFilter = collectionFilter this@CollectionFolderViewModel.useSeriesForPrimary = useSeriesForPrimary this@CollectionFolderViewModel.itemId = itemId itemId.toUUIDOrNull()?.let { @@ -166,15 +169,17 @@ class CollectionFolderViewModel ) val sortAndDirection = - libraryDisplayInfo?.sortAndDirection - ?: initialSortAndDirection - ?: SortAndDirection.DEFAULT + if (collectionFilter.useSavedLibraryDisplayInfo) { + libraryDisplayInfo?.sortAndDirection + } else { + null + } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT val filterToUse = - if (libraryDisplayInfo?.filter != null) { - filter.merge(libraryDisplayInfo.filter) + if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) { + collectionFilter.filter.merge(libraryDisplayInfo.filter) } else { - filter + collectionFilter.filter } loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) @@ -185,18 +190,20 @@ class CollectionFolderViewModel newSort: SortAndDirection = this.sortAndDirection.value!!, viewOptions: ViewOptions? = this.viewOptions.value, ) { - serverRepository.currentUser.value?.let { user -> - viewModelScope.launch(Dispatchers.IO) { - val libraryDisplayInfo = - LibraryDisplayInfo( - userId = user.rowId, - itemId = itemId, - sort = newSort.sort, - direction = newSort.direction, - filter = newFilter, - viewOptions = viewOptions, - ) - libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + if (collectionFilter.useSavedLibraryDisplayInfo) { + serverRepository.currentUser.value?.let { user -> + viewModelScope.launch(Dispatchers.IO) { + val libraryDisplayInfo = + LibraryDisplayInfo( + userId = user.rowId, + itemId = itemId, + sort = newSort.sort, + direction = newSort.direction, + filter = newFilter, + viewOptions = viewOptions, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } } } } @@ -490,7 +497,7 @@ class CollectionFolderViewModel fun CollectionFolderGrid( preferences: UserPreferences, itemId: UUID, - initialFilter: GetItemsFilter, + initialFilter: CollectionFolderFilter, recursive: Boolean, onClickItem: (Int, BaseItem) -> Unit, sortOptions: List, @@ -523,7 +530,7 @@ fun CollectionFolderGrid( fun CollectionFolderGrid( preferences: UserPreferences, itemId: String, - initialFilter: GetItemsFilter, + initialFilter: CollectionFolderFilter, recursive: Boolean, onClickItem: (Int, BaseItem) -> Unit, sortOptions: List, @@ -550,7 +557,7 @@ fun CollectionFolderGrid( ) } val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) - val filter by viewModel.filter.observeAsState(initialFilter) + val filter by viewModel.filter.observeAsState(initialFilter.filter) val loading by viewModel.loading.observeAsState(LoadingState.Loading) val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() @@ -574,11 +581,17 @@ fun CollectionFolderGrid( LoadingState.Success -> { pager?.let { pager -> + val title = + initialFilter.nameOverride + ?: item?.name + ?: item?.data?.collectionType?.name + ?: stringResource(R.string.collection) Box(modifier = modifier) { CollectionFolderGridContent( - preferences, - item, - pager, + preferences = preferences, + item = item, + title = title, + pager = pager, sortAndDirection = sortAndDirection!!, modifier = Modifier.fillMaxSize(), onClickItem = onClickItem, @@ -699,6 +712,7 @@ fun CollectionFolderGrid( fun CollectionFolderGridContent( preferences: UserPreferences, item: BaseItem?, + title: String, pager: List, sortAndDirection: SortAndDirection, onClickItem: (Int, BaseItem) -> Unit, @@ -722,7 +736,6 @@ fun CollectionFolderGridContent( onFilterChange: (GetItemsFilter) -> Unit = {}, ) { val context = LocalContext.current - val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) var showHeader by rememberSaveable { mutableStateOf(true) } var showViewOptions by rememberSaveable { mutableStateOf(false) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index f4fea70c..87fc1da6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -15,6 +15,9 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager @@ -39,6 +42,7 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemFields @@ -55,9 +59,12 @@ class GenreViewModel constructor( private val api: ApiClient, private val imageUrlService: ImageUrlService, + private val serverRepository: ServerRepository, val navigationManager: NavigationManager, ) : ViewModel() { private lateinit var itemId: UUID + + val item = MutableLiveData(null) val loading = MutableLiveData(LoadingState.Pending) val genres = MutableLiveData>(listOf()) @@ -65,8 +72,14 @@ class GenreViewModel loading.value = LoadingState.Loading this.itemId = itemId viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { + val item = + api.userLibraryApi.getItem(itemId = itemId).content.let { + BaseItem(it, false) + } + this@GenreViewModel.item.setValueOnMain(item) val request = GetGenresRequest( + userId = serverRepository.currentUser.value?.id, parentId = itemId, fields = SlimItemFields, ) @@ -187,13 +200,23 @@ fun GenreCardGrid( LoadingState.Success -> { Box(modifier = modifier) { LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } + val item by viewModel.item.observeAsState(null) CardGrid( pager = genres, onClickItem = { _, genre -> viewModel.navigationManager.navigateTo( Destination.FilteredCollection( itemId = itemId, - filter = GetItemsFilter(genres = listOf(genre.id)), + filter = + CollectionFolderFilter( + nameOverride = + listOfNotNull( + genre.name, + item?.title, + ).joinToString(" "), + filter = GetItemsFilter(genres = listOf(genre.id)), + useSavedLibraryDisplayInfo = false, + ), recursive = true, ), ) 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 d71cea69..4af65f1d 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 @@ -10,7 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster @@ -28,7 +28,7 @@ fun CollectionFolderBoxSet( item: BaseItem?, recursive: Boolean, modifier: Modifier = Modifier, - filter: GetItemsFilter = GetItemsFilter(), + filter: CollectionFolderFilter = CollectionFolderFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), playEnabled: Boolean = false, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index 6852abbc..6db471e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.ItemFilterBy -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster @@ -29,7 +29,7 @@ fun CollectionFolderGeneric( recursive: Boolean, playEnabled: Boolean, modifier: Modifier = Modifier, - filter: GetItemsFilter = GetItemsFilter(), + filter: CollectionFolderFilter = CollectionFolderFilter(), filterOptions: List> = DefaultFilterOptions, sortOptions: List = VideoSortOptions, preferencesViewModel: PreferencesViewModel = hiltViewModel(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 2b69c105..c71e0abe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -29,7 +29,7 @@ import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager @@ -164,7 +164,7 @@ fun CollectionFolderLiveTv( preferences = preferences, onClickItem = onClickItem, itemId = folders[folderIndex].id, - initialFilter = GetItemsFilter(), + initialFilter = CollectionFolderFilter(), showTitle = false, recursive = false, sortOptions = VideoSortOptions, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 798683aa..f824a4b9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -23,6 +23,7 @@ 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.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid @@ -115,8 +116,11 @@ fun CollectionFolderMovie( }, itemId = destination.itemId, initialFilter = - GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.MOVIE), + CollectionFolderFilter( + filter = + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.MOVIE), + ), ), showTitle = false, recursive = true, @@ -143,8 +147,11 @@ fun CollectionFolderMovie( }, itemId = destination.itemId, initialFilter = - GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.BOX_SET), + CollectionFolderFilter( + filter = + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.BOX_SET), + ), ), showTitle = false, recursive = true, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index 15e36ad3..47547219 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare @@ -25,7 +25,7 @@ fun CollectionFolderPlaylist( item: BaseItem?, recursive: Boolean, modifier: Modifier = Modifier, - filter: GetItemsFilter = GetItemsFilter(), + filter: CollectionFolderFilter = CollectionFolderFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { var showHeader by remember { mutableStateOf(true) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt index 0540c299..5362ddb5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt @@ -9,7 +9,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster @@ -23,7 +23,7 @@ fun CollectionFolderRecordings( itemId: UUID, recursive: Boolean, modifier: Modifier = Modifier, - filter: GetItemsFilter = GetItemsFilter(), + filter: CollectionFolderFilter = CollectionFolderFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { var showHeader by remember { mutableStateOf(true) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index b32189d4..bf032813 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid @@ -115,8 +116,11 @@ fun CollectionFolderTv( preferences = preferences, itemId = destination.itemId, initialFilter = - GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.SERIES), + CollectionFolderFilter( + filter = + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.SERIES), + ), ), showTitle = false, recursive = true, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index 5157f7d7..a62c58f5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -25,6 +25,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.filter.DefaultForFavoritesFilterOptions import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.preferences.UserPreferences @@ -117,9 +118,12 @@ fun FavoritesPage( onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_movies", initialFilter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.MOVIE), + CollectionFolderFilter( + filter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.MOVIE), + ), ), showTitle = false, recursive = true, @@ -145,9 +149,12 @@ fun FavoritesPage( onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_series", initialFilter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.SERIES), + CollectionFolderFilter( + filter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.SERIES), + ), ), showTitle = false, recursive = true, @@ -173,9 +180,12 @@ fun FavoritesPage( onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_episodes", initialFilter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.EPISODE), + CollectionFolderFilter( + filter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), + ), ), showTitle = false, recursive = true, @@ -202,9 +212,12 @@ fun FavoritesPage( onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_videos", initialFilter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.VIDEO), + CollectionFolderFilter( + filter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.VIDEO), + ), ), showTitle = false, recursive = true, @@ -230,9 +243,12 @@ fun FavoritesPage( onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_playlists", initialFilter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.PLAYLIST), + CollectionFolderFilter( + filter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.PLAYLIST), + ), ), showTitle = false, recursive = true, @@ -258,9 +274,12 @@ fun FavoritesPage( onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_people", initialFilter = - GetItemsFilter( - favorite = true, - override = GetItemsFilterOverride.PERSON, + CollectionFolderFilter( + filter = + GetItemsFilter( + favorite = true, + override = GetItemsFilterOverride.PERSON, + ), ), initialSortAndDirection = SortAndDirection( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index b9452daa..322b0e46 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -5,6 +5,7 @@ package com.github.damontecres.wholphin.ui.nav import androidx.annotation.StringRes import androidx.navigation3.runtime.NavKey import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.ui.data.SortAndDirection @@ -94,7 +95,7 @@ sealed class Destination( @Serializable data class FilteredCollection( val itemId: UUID, - val filter: GetItemsFilter, + val filter: CollectionFolderFilter, val recursive: Boolean, ) : Destination(false) From 2b53a5315520b2411dc5219fb7a2564606650fe3 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 22 Dec 2025 19:16:12 -0500 Subject: [PATCH 8/9] Prevent quick details from using more veritical space if not enough horizontal space (#544) ## Description Prevents the quick details (the dot separated details) from using more vertical space if there is not enough horizontal space. Instead, the row will be clipped at the total width. This PR also gives more horizontal space to the details header on grid pages. This matches the home page now. ### Related issues Fixes #542 --- .../wholphin/ui/components/CollectionFolderGrid.kt | 3 +-- .../wholphin/ui/components/DotSeparatedRow.kt | 9 +++++++-- .../github/damontecres/wholphin/ui/components/Rating.kt | 2 ++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index d7dc7cae..8692d447 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -846,8 +846,7 @@ fun CollectionFolderGridContent( item = focusedItem, modifier = Modifier - .fillMaxWidth(.6f) -// .fillMaxHeight(.25f) + .fillMaxWidth() .height(140.dp) .padding(16.dp), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt index 4b04849c..2c9b1d78 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt @@ -20,14 +20,17 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.tv.material3.LocalTextStyle @@ -54,22 +57,24 @@ fun DotSeparatedRow( text = text, style = textStyle, color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, ) if (communityRating != null || criticRating != null || index != texts.lastIndex) { Dot() } } + val height = with(LocalDensity.current) { textStyle.fontSize.toDp() } communityRating?.let { SimpleStarRating( communityRating = it, - modifier = Modifier, + modifier = Modifier.height(height), ) if (criticRating != null) { Dot() } } criticRating?.let { - TomatoRating(it) + TomatoRating(it, Modifier.height(height)) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt index d4087ea1..c52fb52f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt @@ -102,6 +102,7 @@ fun SimpleStarRating( if (text.isNotNullOrBlank()) { Text( text = text, + maxLines = 1, modifier = Modifier, ) val height = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() } @@ -130,6 +131,7 @@ fun TomatoRating( CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { Text( text = rating.toInt().toString() + "%", + maxLines = 1, modifier = Modifier, ) } From 86e5e5ab54f7e3ce74255244a9670c16226c9818 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 23 Dec 2025 12:43:20 -0500 Subject: [PATCH 9/9] Release v0.3.8