From 8e40ac0869dedbb89c14949d06f0df2cc8d4451c Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 22:41:16 +0100 Subject: [PATCH] Fix TV episode navigation and live resume freshness --- .../wholphin/services/NavigationManager.kt | 7 + .../wholphin/ui/playback/PlayExternalPage.kt | 3 + .../wholphin/ui/playback/PlaybackViewModel.kt | 2 + .../wholphin/util/PlaybackWatchState.kt | 27 ++++ .../util/TrackActivityPlaybackListener.kt | 21 ++- .../ui/playback/PlayExternalViewModelTest.kt | 1 + .../wholphin/util/PlaybackWatchStateTest.kt | 95 ++++++++++++ .../wolfskin/tv/ConductarrHomeSurfaceTest.kt | 3 +- .../wolfskin/tv/HomeRecoveryTest.kt | 15 +- .../wolfskin/tv/LibrarySnapshotStoreTest.kt | 15 ++ .../wolfskin/tv/PersonSwitchThreadingTest.kt | 3 +- .../wolfskin/tv/PlaybackUserData.kt | 9 ++ .../wolfskin/tv/PlayerHandoffTest.kt | 14 +- .../justinvisser/wolfskin/tv/TitleKeysTest.kt | 24 ++++ .../tv/TitlePlaybackNavigationTest.kt | 136 ++++++++++++++++++ .../wolfskin/tv/TitleReadFacadeTest.kt | 3 +- .../justinvisser/wolfskin/tv/WatchReadTest.kt | 135 +++++++++++++++++ .../wolfskin/tv/WatchReturnTest.kt | 77 ++++++++++ .../wolfskin/tv/HomeDestination.kt | 75 +++++++--- .../wolfskin/tv/TitleDestination.kt | 46 ++++-- .../justinvisser/wolfskin/tv/WatchRefresh.kt | 32 +++++ .../wolfskin/tv/WolfskinLibrary.kt | 5 +- .../wolfskin/tv/read/HomeReadFacade.kt | 33 ++--- .../wolfskin/tv/read/LibrarySnapshotStore.kt | 4 +- .../wolfskin/tv/read/PlaybackRead.kt | 23 +++ .../wolfskin/tv/read/TitleReadFacade.kt | 7 +- docker/README.md | 34 ++++- .../justinvisser/wolfskin/design/HomeFocus.kt | 2 +- .../wolfskin/design/HomeHeader.kt | 7 +- .../justinvisser/wolfskin/design/HomeModel.kt | 5 +- .../wolfskin/design/TitleModel.kt | 3 +- .../justinvisser/wolfskin/design/TitlePage.kt | 7 +- .../wolfskin/design/TitleState.kt | 16 ++- .../wolfskin/design/HomeKeysTest.kt | 5 +- 34 files changed, 793 insertions(+), 101 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt 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 3208f4ec..87095f8b 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 @@ -24,6 +24,13 @@ class NavigationManager log() } + /** Replace only the current page, preserving every parent entry. */ + fun replaceTop(destination: Destination) { + if (backStack.isEmpty()) backStack.add(destination) + else backStack[backStack.lastIndex] = destination + log() + } + /** * Go to the specified [Destination], but reset the back stack to Home first */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index b17036b8..421cc279 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -82,9 +82,11 @@ class PlayExternalViewModel ) : ViewModel() { val launched = savedStateHandle.getMutableStateFlow("launched", false) val state = MutableStateFlow(PlayExternalState()) + private var watchScope: String? = null fun init(destination: Destination) { Timber.v("init called: %s", destination) + watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}" state.update { it.copy(loading = LoadingState.Loading) } viewModelScope.launchDefault { val prefs = userPreferencesService.getCurrent() @@ -358,6 +360,7 @@ class PlayExternalViewModel failed = false, ), ) + watchScope?.let { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, it) } } } else { Timber.w( 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 24bd8ee9..9cbbe551 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 @@ -830,10 +830,12 @@ class PlaybackViewModel } val playbackItemState = PlaybackItemState(playback) + val watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}" val activityListener = TrackActivityPlaybackListener( api = api, player = player, + onStopped = { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, watchScope) }, getState = { playbackItemState }, ) player.addListener(activityListener) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt new file mode 100644 index 00000000..3306ca18 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.util + +import android.content.Context +import org.justinvisser.wolfskin.conductarr.FileCacheStore +import java.io.File +import java.io.IOException +import timber.log.Timber +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** Only an acknowledged stop invalidates the captured server/user's watch projections. */ +object PlaybackWatchState { + private val mutableRevisions = MutableStateFlow>(emptyMap()) + val revisions = mutableRevisions.asStateFlow() + + /** Remove persisted watch grids before publishing the captured user's acknowledgement. */ + fun stopped(context: Context, scope: String) { + try { + FileCacheStore(File(context.filesDir, "wolfskin-library-cache")).clear(scope) + } catch (exception: IOException) { + Timber.w(exception, "Could not invalidate playback watch snapshots") + } + val time = System.currentTimeMillis() + mutableRevisions.update { previous -> previous + (scope to maxOf(time, (previous[scope] ?: 0L) + 1L)) } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index 2ab4c2f7..859a04aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -7,6 +7,8 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.playback.CurrentPlayback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.model.api.PlayMethod @@ -31,9 +33,12 @@ import kotlin.time.Duration.Companion.seconds class TrackActivityPlaybackListener( private val api: ApiClient, private val player: Player, + private val onStopped: () -> Unit = {}, private val getState: () -> PlaybackItemState?, ) : Player.Listener { private val coroutineScope = CoroutineScope(WholphinDispatchers.Main) + private val reports = Mutex() + @Volatile private var released = false private val task: TimerTask = object : TimerTask() { override fun run() { @@ -70,19 +75,23 @@ class TrackActivityPlaybackListener( val delay = 5.seconds.inWholeMilliseconds // Every x seconds, check if the video is playing - TIMER.schedule(task, delay, delay) - initialized = true + if (!released) { + TIMER.schedule(task, delay, delay) + initialized = true + } } } } fun release() { -// player.removeListener(this) + if (released) return + released = true task.cancel() TIMER.purge() val position = player.currentPosition.milliseconds + val stoppedState = getState.invoke() launch("reportPlaybackStopped") { - getState.invoke()?.let { state -> + stoppedState?.let { state -> Timber.v("reportPlaybackStopped for ${state.itemId} at $position") if (position < Duration.ZERO) { Timber.w("Negative position when reporting playback stopped: %s", position) @@ -96,6 +105,7 @@ class TrackActivityPlaybackListener( liveStreamId = state.liveStreamId, ), ) + onStopped() } } } @@ -117,6 +127,7 @@ class TrackActivityPlaybackListener( private fun saveActivity(position: Long) { launch("saveActivity") { + if (released) return@launch getState.invoke()?.let { state -> val calcPosition = withContext(WholphinDispatchers.Main) { @@ -152,7 +163,7 @@ class TrackActivityPlaybackListener( ) { coroutineScope.launchIO { try { - block.invoke(this) + reports.withLock { block.invoke(this) } } catch (ex: Exception) { Timber.w(ex, "Exception during %s", name) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt index 511dbaa2..c6bc32f0 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt @@ -114,6 +114,7 @@ class PlayExternalViewModelTest { every { api.subtitleApi } returns subtitleApi every { api.playStateApi } returns playStateApi every { serverRepository.currentUser } returns null + every { serverRepository.currentServer } returns null coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie) coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt new file mode 100644 index 00000000..73791251 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt @@ -0,0 +1,95 @@ +package com.github.damontecres.wholphin.util + +import android.app.Application +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import androidx.media3.common.Player +import io.mockk.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.* +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.api.operations.PlayStateApi +import org.jellyfin.sdk.model.api.PlayMethod +import com.github.damontecres.wholphin.ui.successResponse +import org.junit.Assert.* +import org.junit.Test +import java.util.UUID +import org.justinvisser.wolfskin.conductarr.FileCacheStore +import org.justinvisser.wolfskin.conductarr.CacheEntry +import java.io.File + +/** Return refresh waits for the stop write rather than racing its acknowledgement. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class PlaybackWatchStateTest { + @Test fun revisionAppearsOnlyAfterTheCapturedUsersStopIsAcknowledged() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val previous = WholphinDispatchers.IO + Dispatchers.setMain(dispatcher) + WholphinDispatchers.IO = dispatcher + val api = mockk() + val reports = mockk() + every { api.playStateApi } returns reports + val player = mockk { every { currentPosition } returns 751_246L } + val scope = "server:${UUID.randomUUID()}" + val another = "server:${UUID.randomUUID()}" + val context = RuntimeEnvironment.getApplication() + val cache = FileCacheStore(File(context.filesDir, "wolfskin-library-cache")) + val saved = CacheEntry("old progress", null, 1L, 1L, group = "library") + cache.write(scope, "library:movies", saved) + cache.write(another, "library:movies", saved) + val acknowledgement = CompletableDeferred() + coEvery { reports.reportPlaybackStopped(any()) } coAnswers { + acknowledgement.await() + successResponse(Unit) + } + val state = PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY) + val listener = TrackActivityPlaybackListener(api, player, onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { state }) + try { + listener.release() + runCurrent() + assertNull(PlaybackWatchState.revisions.value[scope]) + assertNotNull(cache.read(scope, "library:movies")) + acknowledgement.complete(Unit) + advanceUntilIdle() + assertNotNull(PlaybackWatchState.revisions.value[scope]) + assertNull(PlaybackWatchState.revisions.value[another]) + assertNull(cache.read(scope, "library:movies")) + assertEquals(saved, cache.read(another, "library:movies")) + coVerify(exactly = 1) { reports.reportPlaybackStopped(match { it.itemId == state.itemId && it.positionTicks == 7_512_460_000L }) } + listener.release() + advanceUntilIdle() + coVerify(exactly = 1) { reports.reportPlaybackStopped(any()) } + } finally { + WholphinDispatchers.IO = previous + Dispatchers.resetMain() + } + } + + @Test fun failedStopDoesNotPublishAFictitiousAcknowledgement() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val previous = WholphinDispatchers.IO + Dispatchers.setMain(dispatcher) + WholphinDispatchers.IO = dispatcher + val api = mockk() + val reports = mockk() + every { api.playStateApi } returns reports + coEvery { reports.reportPlaybackStopped(any()) } throws java.io.IOException("Stop rejected") + val scope = "server:${UUID.randomUUID()}" + val listener = TrackActivityPlaybackListener(api, mockk { every { currentPosition } returns 751_246L }, + onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY) }) + try { + listener.release() + advanceUntilIdle() + assertNull(PlaybackWatchState.revisions.value[scope]) + } finally { + WholphinDispatchers.IO = previous + Dispatchers.resetMain() + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt index c4e63d27..7842289d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt @@ -52,8 +52,9 @@ class ConductarrHomeSurfaceTest { session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) runBlocking { session.authenticate() } val home = mockk() - coEvery { home.read(user.id) } returns HomeLibrary(resume = listOf( + coEvery { home.readWatch(user.id) } returns HomeLibrary(resume = listOf( HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true))) + coEvery { home.readLibrary(user.id) } returns HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt index dc0b780d..128dae1e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt @@ -43,11 +43,13 @@ class HomeRecoveryTest { val started = CompletableDeferred() val response = CompletableDeferred() val facade = mockk() - coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() } + coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() } + coEvery { facade.readLibrary(any()) } returns HomeLibrary() val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) } started.await() assertEquals(film.id, model.snapshot.rows.first().cards.first().id) + assertFalse(model.snapshot.rows.first().watchReady) response.completeExceptionally(IOException("fixture offline")) refresh.await() assertFalse(model.snapshot.jellyfinAvailable) @@ -62,8 +64,9 @@ class HomeRecoveryTest { cache.write("${server.id}:${personA.id}", library, 1000L) val startedA = CompletableDeferred() val responseA = CompletableDeferred() - coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } - coEvery { facade.read(personB.id) } throws IOException("fixture offline") + coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } + coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline") + coEvery { facade.readLibrary(any()) } returns HomeLibrary() val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) } startedA.await() @@ -80,7 +83,8 @@ class HomeRecoveryTest { @Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking { val facade = mockk() - coEvery { facade.read(personA.id) } returns library + coEvery { facade.readWatch(personA.id) } returns library + coEvery { facade.readLibrary(any()) } returns HomeLibrary() val cache = mockk() every { cache.read(any()) } returns null every { cache.write(any(), any(), any()) } throws IOException("fixture disk full") @@ -93,7 +97,8 @@ class HomeRecoveryTest { @Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking { val facade = mockk() - coEvery { facade.read(personA.id) } returns library + coEvery { facade.readWatch(personA.id) } returns library + coEvery { facade.readLibrary(any()) } returns HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt index 279c3966..4e2917c3 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt @@ -48,6 +48,21 @@ class LibrarySnapshotStoreTest { assertFalse(reads.last().unreachable) } + @Test fun acknowledgedPlaybackInvalidatesOnlyOlderWatchSnapshotsBeforeTheTtl() = runBlocking { + var now = 1000L + val store = LibrarySnapshotStore(MemoryCacheStore()) { now } + val old = listOf(LibraryTitle("film", "Film", "movie", progress = .1f)) + store.read("server:justin", "movies") { flowOf(old) }.toList() + store.read("server:mindel", "movies") { flowOf(old) }.toList() + now = 1002L + val updated = listOf(old.single().copy(progress = .6f)) + val reads = store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { flowOf(updated) }.toList() + assertEquals(.1f, reads.first().items.single().progress) + assertEquals(.6f, reads.last().items.single().progress) + assertEquals(updated, store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { error("Already revalidated") }.toList().single().items) + assertEquals(old, store.read("server:mindel", "movies") { error("Other viewer stays cached") }.toList().single().items) + } + @Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking { val partial = listOf(LibraryTitle("partial", "First film", "movie")) val complete = partial + LibraryTitle("last", "Last film", "movie") diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt index 20ea6d9b..6a5936f8 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -214,8 +214,9 @@ class PersonSwitchThreadingTest { every { repository.currentUser } returns user every { repository.currentServer } returns server val home = mockk() - coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf( + coEvery { home.readWatch(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf( org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true))) + coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt new file mode 100644 index 00000000..b8e86ca0 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt @@ -0,0 +1,9 @@ +package org.justinvisser.wolfskin.tv + +import org.jellyfin.sdk.model.api.UserItemDataDto +import java.util.UUID + +/** Checkpoints use the SDK's required identity and watch fields. */ +internal fun playbackUserData(itemId: UUID, ticks: Long, played: Boolean = false, playCount: Int = 0): UserItemDataDto = + UserItemDataDto(playbackPositionTicks = ticks, playCount = playCount, isFavorite = false, + lastPlayedDate = null, played = played, key = "", itemId = itemId) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt index 92c0a248..dca356a7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt @@ -58,7 +58,7 @@ class PlayerHandoffTest { @Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() { val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline") val title = snapshot.rows.first().cards.first() - coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline") + coEvery { facade.preparePlayback(user.id, title) } throws IOException("fixture offline") rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) } @@ -80,14 +80,16 @@ class PlayerHandoffTest { @Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() { val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() val id = UUID.fromString("00000000-0000-0000-0000-000000000003") - coEvery { facade.playableId(user.id, title) } returns id + coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto( + id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE, + userData = playbackUserData(id, 11_362_740_000L, playCount = 2)) val view = shell.view view.cards["hero"] = 0 - rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) } + rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) } awaitModel { navigation.backStack.last() is Destination.Playback } val destination = navigation.backStack.last() as Destination.Playback assertEquals(id, destination.itemId) - assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs) + assertEquals(1_136_274L, destination.positionMs) assertTrue(destination.fullScreen) navigation.goBack() assertTrue(navigation.backStack.single() is Destination.Home) @@ -99,11 +101,11 @@ class PlayerHandoffTest { @Test fun anotherPersonsSessionCannotResolveOrStartPlayback() { val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() val otherUser = user.copy(name = "Justin") - rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, otherUser) } + rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, otherUser) } awaitModel { model.toast != null } assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast) assertTrue(navigation.backStack.single() is Destination.Home) - coVerify(exactly = 0) { facade.playableId(any(), any()) } + coVerify(exactly = 0) { facade.preparePlayback(any(), any()) } } private fun awaitModel(condition: () -> Boolean) { rule.waitUntil(5000) { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 7aa9bfd7..0b6dac33 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -11,6 +11,9 @@ import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performMouseInput import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.text.TextLayoutResult @@ -62,6 +65,27 @@ class TitleKeysTest { } } } + @Test fun downFocusesThePlayableLaterSeasonAndScrollsItIntoViewWithoutLosingBrowsingMemory() { + render("Andor") { page -> + page.copy(item = page.item.copy(seasons = 30), seasonNumbers = (1..30).toList(), selectedSeason = 29, + playTarget = page.playTarget!!.copy(season = 29)) + } + press(Key.DirectionDown) + assertEquals("seasons", state.row) + assertEquals(28, state.column) + rule.onNode(hasText("Season 29") and hasClickAction()).assertIsDisplayed() + press(Key.DirectionLeft) + assertEquals(27, state.column) + rule.onNode(hasText("Season 28") and hasClickAction()).assertIsDisplayed() + press(Key.DirectionUp) + press(Key.DirectionDown) + assertEquals(27, state.column) + press(Key.DirectionCenter) + assertEquals(28, state.season) + assertEquals(28, state.requestedSeason) + assertEquals(TitleAction.Season(28), effects.last()) + } + private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } } private fun replay(end: String): Int { val path = paths.single { it.getString("end") == end } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt new file mode 100644 index 00000000..75c92ed5 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt @@ -0,0 +1,136 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID + +/** Production title actions preserve parents and resolve live playback instead of cached offsets. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class TitlePlaybackNavigationTest { + private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001") + private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002") + private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003") + private val person = ShellPerson("justin", "Justin", "J", false) + private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + private val series = TitleItem(seriesId.toString(), "Series", "tv", seasons = 5, held = true) + private val first = TitleItem(firstId.toString(), "First episode", "episode", held = true, season = 3, episode = 1, resumeSeconds = 12) + private val second = first.copy(id = secondId.toString(), name = "Second episode", episode = 2) + + @Test fun repeatedEpisodeChoicesReplaceOnlyEpisodesAndBackPreservesTheirSeriesParent() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val navigation = NavigationManager() + val facade = mockk() + coEvery { facade.readSaved(any(), any()) } returns null + coEvery { facade.read(any(), any(), any()) } coAnswers { + val id = firstArg() + TitlePageData(series, playTarget = first, watchTarget = if (id == seriesId) series else first.copy(id = id.toString()), + episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor"))) + } + val session = ConductarrSession(MemoryCacheStore()) + val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true)) + fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true) + try { + navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString()))) + open(seriesId) + advanceUntilIdle() + model.title!!.click("episodes", 0) + assertEquals(3, navigation.backStack.size) + assertEquals(firstId, (navigation.backStack.last() as Destination.MediaItem).itemId) + open(firstId) + advanceUntilIdle() + for (id in listOf(secondId, firstId, secondId)) { + model.title!!.click("episodes", if (id == firstId) 0 else 1) + assertEquals(3, navigation.backStack.size) + assertEquals(id, (navigation.backStack.last() as Destination.MediaItem).itemId) + open(id) + advanceUntilIdle() + } + model.title!!.click("related", 0) + assertEquals(4, navigation.backStack.size) + navigation.goBack() + model.title!!.click("cast", 0) + assertEquals(4, navigation.backStack.size) + navigation.goBack() + model.title!!.back(ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false }) + assertEquals(listOf(Destination.Home(), Destination.Wolfskin(titleRoute(seriesId.toString()))), navigation.backStack.toList()) + navigation.goBack() + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + navigation.navigateTo(Destination.Wolfskin(titleRoute(firstId.toString()))) + open(firstId) + advanceUntilIdle() + model.title!!.click("episodes", 1) + navigation.goBack() + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + } finally { + model.viewModelScope.cancel() + session.close() + Dispatchers.resetMain() + } + } + + @Test fun primarySeriesActionReResolvesNextUpButRestartAndExplicitEpisodesKeepTheirIdentity() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val navigation = NavigationManager() + val facade = mockk() + coEvery { facade.readSaved(any(), any()) } returns null + coEvery { facade.read(any(), any(), any()) } coAnswers { + TitlePageData(series, first, watchTarget = if (firstArg() == seriesId) series else first) + } + coEvery { facade.preparePlayback(seriesId) } returns BaseItemDto(id = secondId, type = BaseItemKind.EPISODE, + userData = playbackUserData(secondId, 11_362_740_000L, playCount = 2)) + coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE, + userData = playbackUserData(firstId, 960_780_000L, playCount = 1)) + val session = ConductarrSession(MemoryCacheStore()) + val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true)) + try { + model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false) + advanceUntilIdle() + model.title!!.click("header", 0) + advanceUntilIdle() + assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last()) + navigation.goBack() + model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" }) + advanceUntilIdle() + assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last()) + navigation.goBack() + model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false) + advanceUntilIdle() + model.title!!.click("header", 0) + advanceUntilIdle() + assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last()) + coVerify(exactly = 1) { facade.preparePlayback(seriesId) } + } finally { + model.viewModelScope.cancel() + session.close() + Dispatchers.resetMain() + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt index fde2ff9c..fb7bf06e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -128,7 +128,7 @@ class TitleReadFacadeTest { } @Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest { - coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) + coEvery { library.getItem(firstId) } returns successResponse(file(firstId).copy(userData = playbackUserData(firstId, 200_000_000L))) facade.read(firstId, null, preferences) coEvery { library.getItem(any()) } throws java.io.IOException("offline") val saved = facade.readSaved(firstId, preferences)!! @@ -139,6 +139,7 @@ class TitleReadFacadeTest { assertEquals(TitleConnection.SignedOut, saved.connection) assertTrue(saved.item.ratings.isEmpty()) assertEquals(firstId.toString(), saved.watchTarget.id) + assertFalse(screen.options.any { it.id == "play" || it.id == "restart" }) } @Test fun diskCopiesNeverCrossViewerIdentity() = runTest { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt new file mode 100644 index 00000000..a7901727 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt @@ -0,0 +1,135 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.content.Context +import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.ui.successResponse +import io.mockk.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.* +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.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.TvShowsApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import java.util.UUID +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset + +/** Recorded Jellyfin watch shapes exercise newer checkpoints and independent Home publication. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class WatchReadTest { + private val api = mockk() + private val library = mockk() + private val shows = mockk() + private val items = mockk() + private val images = mockk(relaxed = true) + private val userId = UUID.fromString("167649f8-00c9-44b5-a86a-f371eccc2d2e") + private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003") + private val json = Json { ignoreUnknownKeys = true } + // Selected fields from the read-only Resume and NextUp replies, without credentials. + private val resumeWire = """{"Items":[{"Id":"a8ed46755a50a5dfb443dd5cdd7125a3","Name":"Sex and the Country","Type":"Episode","ParentIndexNumber":4,"IndexNumber":9,"RunTimeTicks":17507200000,"UserData":{"PlaybackPositionTicks":960780000,"Played":false,"PlayCount":1,"IsFavorite":false,"LastPlayedDate":null,"Key":"","ItemId":"a8ed46755a50a5dfb443dd5cdd7125a3"}}],"TotalRecordCount":1,"StartIndex":0}""" + private val nextWire = """{"Items":[{"Id":"d6a9588e91a4a22ecb9ae279eb7e09d6","Name":"Me and My Town","Type":"Episode","SeriesName":"Desperate Housewives","ParentIndexNumber":5,"IndexNumber":9,"RunTimeTicks":25368960000,"UserData":{"PlaybackPositionTicks":11362740000,"Played":false,"PlayCount":2,"IsFavorite":false,"LastPlayedDate":"2026-11-06T10:39:27.1772471Z","Key":"","ItemId":"d6a9588e91a4a22ecb9ae279eb7e09d6"}}],"TotalRecordCount":1,"StartIndex":0}""" + private fun decode(wire: String) = json.decodeFromString(BaseItemDtoQueryResult.serializer(), wire).items.first() + + @Before fun setup() { + every { api.userLibraryApi } returns library + every { api.tvShowsApi } returns shows + every { api.itemsApi } returns items + mockkObject(WholphinApplication.Companion) + every { WholphinApplication.instance } returns mockk { + every { resources } returns RuntimeEnvironment.getApplication().resources + } + } + + @After fun teardown() { unmockkObject(WholphinApplication.Companion) } + + @Test fun bothFacadesUseTheLiveCheckpointAndSeriesNextUpWhileExplicitEpisodeKeepsItsIdentity() = runTest { + val resume = decode(resumeWire) + val next = decode(nextWire) + val series = BaseItemDto(id = seriesId, type = BaseItemKind.SERIES) + coEvery { library.getItem(itemId = seriesId, userId = userId) } returns successResponse(series) + coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(resume) + coEvery { library.getItem(itemId = next.id, userId = userId) } returns successResponse(next) + coEvery { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) } returns successQueryResult(listOf(next)) + val home = HomeReadFacade(api, images, mockk()) + val repository = mockk { + every { currentUser } returns mockk { every { id } returns userId } + } + val title = TitleReadFacade(api, images, mockk(), repository, mockk(), mockk()) + val cached = HomeTitle(resume.id.toString(), "Sex and the Country", "episode", resumeSeconds = 10.0) + assertEquals(96_078L, home.preparePlayback(userId, cached)!!.playbackPositionMs()) + assertEquals(96_078L, title.preparePlayback(resume.id)!!.playbackPositionMs()) + assertEquals(next.id, home.preparePlayback(userId, HomeTitle(seriesId.toString(), "Series", "tv"))!!.id) + val selected = title.preparePlayback(seriesId)!! + assertEquals(next.id, selected.id) + assertEquals(1_136_274L, selected.playbackPositionMs()) + coVerify(exactly = 2) { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) } + val completed = resume.copy(userData = resume.userData!!.copy(playbackPositionTicks = 0, played = true)) + coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(completed) + assertEquals(0L, home.preparePlayback(userId, cached)!!.playbackPositionMs()) + assertEquals(true, title.preparePlayback(resume.id)!!.userData?.played) + } + + @Test fun freshResumeAndNextUpPaintBeforeSlowLibraryAndSurviveItsFailure() = runTest { + WholphinDispatchers.configure(StandardTestDispatcher(testScheduler)) + val server = JellyfinServer(seriesId, "Fixture", "https://example.invalid", null) + val user = JellyfinUser(id = userId, serverId = server.id, name = "Mindel", accessToken = null) + val person = ShellPerson("mindel", "Mindel", "M", false, userId.toString()) + val resume = decode(resumeWire) + val next = decode(nextWire) + coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(resume)) + coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next)) + val facade = spyk(HomeReadFacade(api, images, mockk())) + val releaseLibrary = CompletableDeferred() + coEvery { facade.readLibrary(userId) } coAnswers { releaseLibrary.await(); throw java.io.IOException("Library failed") } + val cache = mockk(relaxed = true) + every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L) + val session = ConductarrSession(MemoryCacheStore()) + val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) + try { + val loading = launch { model.loadLibrary(server, user, person) } + runCurrent() + assertFalse(loading.isCompleted) + assertTrue(model.snapshot.rows.first().watchReady) + assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001) + releaseLibrary.complete(Unit) + loading.join() + assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001) + assertTrue(model.snapshot.jellyfinAvailable) + } finally { + model.viewModelScope.cancel() + session.close() + WholphinDispatchers.reset() + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt new file mode 100644 index 00000000..494c8412 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt @@ -0,0 +1,77 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.util.PlaybackWatchState +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.cancel +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.HomeLibraryCache +import org.justinvisser.wolfskin.tv.read.HomeReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference + +/** A still-composed Home must paint the checkpoint acknowledged after its first return read. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class WatchReturnTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun acknowledgedStopRefreshesVisibleHeroWithoutRecreatingHomeOrLosingFocus() { + val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) + val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null) + val person = ShellPerson("mindel", "Mindel", "M", false, user.id.toString()) + val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false } + val episode = HomeTitle("episode", "Me and My Town", "episode", runtimeSeconds = 2536, resumeSeconds = 10.0, held = true, seriesTitle = "Desperate Housewives", season = 5, episode = 9) + val current = AtomicReference(episode) + val facade = mockk() + every { facade.audiences } returns null + coEvery { facade.readWatch(user.id) } coAnswers { HomeLibrary(resume = listOf(current.get())) } + coEvery { facade.readLibrary(user.id) } returns HomeLibrary() + val cache = mockk(relaxed = true) + every { cache.read(any()) } returns null + val session = ConductarrSession(MemoryCacheStore()) + val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) + val oldFixtures = ConductarrPreference.fixtures + ConductarrPreference.updateFixtures(false) + try { + rule.setContent { WolfskinTheme { + ShellSurface(shell) { HomeDestination(shell, server, user, it, model) } + } } + rule.waitUntil(5000) { + shadowOf(Looper.getMainLooper()).idle() + model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 10.0 + } + val view = shell.rememberedView("home") + rule.runOnIdle { shell.navFocused = false; view.cards["hero"] = 0; current.set(episode.copy(resumeSeconds = 1136.274)) } + PlaybackWatchState.stopped(rule.activity, "${server.id}:${user.id}") + rule.waitUntil(5000) { + shadowOf(Looper.getMainLooper()).idle() + model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 1136.274 + } + assertSame(view, shell.rememberedView("home")) + assertEquals(0, view.cards["hero"]) + assertTrue(model.snapshot.rows.first().watchReady) + } finally { + model.viewModelScope.cancel() + session.close() + ConductarrPreference.updateFixtures(oldFixtures) + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index c8ab24cc..35f10b1e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -18,10 +18,12 @@ import java.util.Date import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.coroutineScope +import org.justinvisser.wolfskin.tv.read.playbackPositionMs import org.justinvisser.wolfskin.design.* import org.justinvisser.wolfskin.tv.read.HomeLibraryCache import org.justinvisser.wolfskin.tv.read.HomeReadFacade @@ -50,9 +52,9 @@ class WolfskinHomeViewModel @Inject constructor( suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { catalogueJob?.cancel() - withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } + withContext(WholphinDispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } if (retry && conductarr.identity.value?.signedIn != true) { - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) } } @@ -74,11 +76,12 @@ class WolfskinHomeViewModel @Inject constructor( toast = null snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading) } - val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } } + val saved = withContext(WholphinDispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } } if (revision != loadRevision) return var libraryCopy = saved?.first ?: HomeLibrary() var libraryAvailable = true var librarySavedAt: String? = null + var watchReady = false val identity = conductarr.identity.value var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn) fun publish() { @@ -89,8 +92,9 @@ class WolfskinHomeViewModel @Inject constructor( DiscoverStatus.Unavailable -> HomeAvailability.Unavailable else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready } - snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable), - status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt) + snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable).map { + if (it.kind == HomeRowKind.Hero) it.copy(watchReady = watchReady) else it + }, status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt) } catalogueSnapshot = catalogueCopy publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } } @@ -107,20 +111,43 @@ class WolfskinHomeViewModel @Inject constructor( if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue) } } - try { - val library = withContext(Dispatchers.IO) { home.read(user.id) } - val savedAt = System.currentTimeMillis() - libraryCopy = library - publish() - withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Saving cannot delay first paint or turn a reachable source into an outage. */ } } - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - if (revision != loadRevision) return - libraryAvailable = false - librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) } - publish() - if (retry) showToast(RecoveryMessage.StillUnreachable) + suspend fun save() { + val library = libraryCopy + withContext(WholphinDispatchers.IO) { + try { cache.write(scope, library, System.currentTimeMillis()) } catch (_: java.io.IOException) { /* Disk failure cannot revoke fresh watch data. */ } + } } + coroutineScope { + launch { + try { + val watch = withContext(WholphinDispatchers.IO) { home.readWatch(user.id) } + if (revision != loadRevision) return@launch + libraryCopy = libraryCopy.copy(resume = watch.resume, nextUp = watch.nextUp) + watchReady = true + libraryAvailable = true + publish() + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + if (revision != loadRevision) return@launch + libraryAvailable = false + librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) } + publish() + if (retry) showToast(RecoveryMessage.StillUnreachable) + } + } + launch { + try { + val shelves = withContext(WholphinDispatchers.IO) { home.readLibrary(user.id) } + if (revision != loadRevision) return@launch + libraryCopy = libraryCopy.copy(newest = shelves.newest, destinations = shelves.destinations, destinationTotals = shelves.destinationTotals) + publish() + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + if (revision == loadRevision && retry) showToast(RecoveryMessage.StillUnreachable) + } + } + } + if (revision == loadRevision) save() } suspend fun readAhead(lane: String?) { @@ -138,6 +165,7 @@ class WolfskinHomeViewModel @Inject constructor( is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell) is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id))) is HomeAction.Play -> viewModelScope.launch { + val personKey = shell.person.key val selectedId = shell.person.jellyfinUserId val matchesUser = if (selectedId != null) selectedId == user.id.toString() else shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester) @@ -146,9 +174,9 @@ class WolfskinHomeViewModel @Inject constructor( return@launch } try { - val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) } - if (id == null) showToast(RecoveryMessage.PlaybackUnavailable) - else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L)) + val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) } + if (item == null) showToast(RecoveryMessage.PlaybackUnavailable) + else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs())) } catch (exception: Exception) { if (exception is CancellationException) throw exception showToast(RecoveryMessage.PlaybackUnavailable) @@ -204,7 +232,8 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle( @Composable fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) { val identity by model.conductarrState.collectAsState() - LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person) } + val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}") + LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { model.load(server, user, shell.person) } val view = shell.rememberedView("home") LaunchedEffect(view.row, model.snapshot.rows) { val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index e457aa86..671959a8 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -48,6 +48,7 @@ import org.justinvisser.wolfskin.design.TitleReadStatus import org.justinvisser.wolfskin.design.TitleState import org.justinvisser.wolfskin.design.TitleSurface import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.justinvisser.wolfskin.tv.read.playbackPositionMs import java.util.UUID import javax.inject.Inject @@ -79,6 +80,7 @@ class WolfskinTitleViewModel @Inject constructor( private val saveMutex = Mutex() private var performerPage = 1 var conductarrAction: ((TitleAction) -> Unit)? = null + val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}" fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route)) fun popRoute() = navigation.goBack() @@ -87,6 +89,14 @@ class WolfskinTitleViewModel @Inject constructor( /** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */ fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) { if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return + if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key && + title?.data?.status == TitleReadStatus.Ready && !destination.startsWith("person/") && fixture == null) { + loadJob?.cancel() + preferences = userPreferences + title?.let { it.update(it.data.copy(watchReady = false)) } + loadJob = viewModelScope.launch { readTitle(Uri.decode(destination.substringAfter('/'))) } + return + } route = destination loadJob?.cancel() person = null @@ -132,7 +142,7 @@ class WolfskinTitleViewModel @Inject constructor( if (title !== current) return if (heldId != null) { if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) } - held = facade.read(heldId, current.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences) + held = facade.read(heldId, current.requestedSeason, preferences) current.update(held) } } catch (exception: Exception) { @@ -223,13 +233,21 @@ class WolfskinTitleViewModel @Inject constructor( when (action) { TitleAction.Pop -> navigation.goBack() is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id))) - is TitleAction.OpenTitle -> navigation.navigateTo(action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { - "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE - "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE - else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES - }) } ?: Destination.Wolfskin(titleRoute(action.item.id))) - is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000) - is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0) + is TitleAction.OpenTitle -> { + val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { + "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE + "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE + else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES + }) } ?: Destination.Wolfskin(titleRoute(action.item.id)) + if (action.episodeSelection && title?.data?.watchTarget?.kind == "episode") navigation.replaceTop(destination) + else navigation.navigateTo(destination) + } + is TitleAction.Play -> { + val opened = title?.data?.watchTarget + play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(), + restart = action.restart) + } + is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true) else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer." is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action) is TitleAction.Season -> { @@ -261,12 +279,15 @@ class WolfskinTitleViewModel @Inject constructor( } } - private fun play(id: UUID?, position: Long) { + private fun play(id: UUID?, restart: Boolean) { + val current = title + val playbackScope = watchScope viewModelScope.launch { try { if (id == null) throw IllegalArgumentException("No Jellyfin playback identity") - facade.preparePlayback(id) - navigation.navigateTo(Destination.Playback(id, position)) + val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item") + if (title !== current || watchScope != playbackScope) return@launch + navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs())) } catch (exception: Exception) { if (exception is CancellationException) throw exception title?.toast = RecoveryMessage.PlaybackUnavailable @@ -308,7 +329,8 @@ class WolfskinTitleViewModel @Inject constructor( @Composable fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel(), fixtures: Boolean = ConductarrPreference.fixtures) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() - LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures) { viewModel.load(route, shell.person, preferences, fixtures, force = true) } + val watchRefresh = rememberWatchRefresh(viewModel.watchScope) + LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) } val title = viewModel.title?.takeIf { it.person.key == shell.person.key } val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null LaunchedEffect(title?.data?.status) { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt new file mode 100644 index 00000000..fb44b681 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt @@ -0,0 +1,32 @@ +package org.justinvisser.wolfskin.tv + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.github.damontecres.wholphin.util.PlaybackWatchState + +/** Re-read on entry, foreground and successful stop, even when a screen remains composed. */ +@Composable +internal fun rememberWatchRefresh(scope: String?): Pair { + val revisions by PlaybackWatchState.revisions.collectAsState() + var foreground by remember(scope) { mutableIntStateOf(0) } + val owner = LocalLifecycleOwner.current + DisposableEffect(owner, scope) { + var initialResume = owner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + if (initialResume) initialResume = false else foreground++ + } + } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + return (revisions[scope] ?: 0L) to foreground +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt index 0301d3dc..c73a68ee 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -72,7 +72,8 @@ fun WolfskinLibraryContent( ShellPlaceholder("See all list unavailable", modifier) return } - LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) { + val watchRefresh = rememberWatchRefresh(cacheScope) + LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null, watchRefresh) { if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) { val wasLoading = grid.status == LibraryStatus.Loading if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab) @@ -82,7 +83,7 @@ fun WolfskinLibraryContent( grid.status = LibraryStatus.Ready } else { val store = withContext(Dispatchers.IO) { snapshots.value } - store.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot -> + store.read(cacheScope, route, force = grid.refreshRevision > 0, invalidatedAtMillis = watchRefresh.first) { facade.readLibrary(user.id, route) }.collect { snapshot -> val focusedId = grid.focusedTitle?.id grid.source = snapshot.items focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt index 1867ae60..bb86e68a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt @@ -23,7 +23,8 @@ import org.justinvisser.wolfskin.design.HomeTitle /** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) { val audiences get() = libraries.audiences - suspend fun read(userId: UUID): HomeLibrary { + /** Watch rows are publishable without waiting for any library shelf. */ + suspend fun readWatch(userId: UUID): HomeLibrary { val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id -> @@ -35,6 +36,11 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val } val resume = resumeItems.map { title(it, series) } val next = nextItems.map { title(it, series) } + return HomeLibrary(resume = resume, nextUp = next) + } + + /** Library failures do not discard an already successful watch read. */ + suspend fun readLibrary(userId: UUID): HomeLibrary { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items val destinations = linkedMapOf>() val newest = mutableListOf() @@ -46,23 +52,18 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val totals[key] = (totals[key] ?: 0) + response.totalRecordCount newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map { title(it) }) } - return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals) + return HomeLibrary(newest = newest.sortedByDescending { it.addedAt }.take(12), destinations = destinations, destinationTotals = totals) } - suspend fun playableId(userId: UUID, title: HomeTitle): UUID? { - title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id -> - val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content - if (item.type == BaseItemKind.SERIES) { - return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull() - ?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull()) - ?.takeIf { it.type == BaseItemKind.EPISODE }?.id - } - return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE } - } - val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE) - return api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull { - it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode)) - }?.id + /** Resolve the current checkpoint even when the hero was painted from disk. */ + suspend fun preparePlayback(userId: UUID, title: HomeTitle): BaseItemDto? { + val id = runCatching { UUID.fromString(title.id) }.getOrNull() ?: run { + val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE) + api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull { + it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode)) + }?.id + } ?: return null + return readPlayback(api, userId, id) } private fun title(item: BaseItemDto, series: Map = emptyMap()): HomeTitle { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt index 85395ba0..fffe1f2c 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt @@ -19,13 +19,13 @@ data class LibrarySnapshot(val items: List, val savedAtMillis: Lon /** Library snapshots share the existing atomic disk store, partitioned by server and person. */ class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) { - fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow>): Flow = flow { + fun read(scope: String, destination: String, force: Boolean = false, invalidatedAtMillis: Long = 0L, load: () -> Flow>): Flow = flow { val key = "library:$destination" val saved = try { cache.read(scope, key) } catch (_: IOException) { null } val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } } if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false)) val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong() - if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow + if (!force && saved != null && titles != null && saved.validatedAtMillis > invalidatedAtMillis && now() - saved.validatedAtMillis < lifetime) return@flow var current: List = emptyList() try { load().collect { chunk -> diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt new file mode 100644 index 00000000..ad17d57b --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt @@ -0,0 +1,23 @@ +package org.justinvisser.wolfskin.tv.read + +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import java.util.UUID + +/** A click resolves its target and checkpoint from Jellyfin, never from a painted projection. */ +internal suspend fun readPlayback(api: ApiClient, userId: UUID?, id: UUID): BaseItemDto? { + val opened = api.userLibraryApi.getItem(itemId = id, userId = userId).content + val target = if (opened.type == BaseItemKind.SERIES) { + api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1, enableResumable = true, enableUserData = true) + .content.items.firstOrNull()?.id + ?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1, enableUserData = true).content.items.firstOrNull()?.id + } else opened.id + val item = if (target == opened.id) opened else target?.let { api.userLibraryApi.getItem(itemId = it, userId = userId).content } + return item?.takeIf { it.type == BaseItemKind.EPISODE || it.type == BaseItemKind.MOVIE } +} + +/** Preserve subsecond server checkpoints, including resumable rewatches marked Played. */ +internal fun BaseItemDto.playbackPositionMs(): Long = (userData?.playbackPositionTicks ?: 0L) / 10_000L diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index 13676b12..ff727717 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -77,7 +77,7 @@ class TitleReadFacade @Inject constructor( val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items")) val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } } build(rows, numbers, saved.optInt("selectedSeason"), saved.getString("watch"), saved.optString("target").takeIf { it.isNotEmpty() }, preferences) - .copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }) + .copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }, watchReady = false) } catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null } } @@ -122,8 +122,9 @@ class TitleReadFacade @Inject constructor( return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people) } - /** A failed reachability read leaves navigation on the title instead of opening a dead player. */ - suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) } + /** Series actions re-resolve NextUp; explicit episodes and Restart retain their exact identity. */ + suspend fun preparePlayback(id: UUID): BaseItemDto? = + readPlayback(api, repository.currentUser?.id, id) /** Track selection uses the same repository consumed by the upstream player. */ suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) { diff --git a/docker/README.md b/docker/README.md index b68d58c6..fa36fcc0 100644 --- a/docker/README.md +++ b/docker/README.md @@ -315,16 +315,29 @@ an untouched fresh entry focuses hero button zero; intervening nav input prevent that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus` release/repeat guard and Back ladder. -`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release -variants read the selected user's Resume, Next up, newest titles and destination -shelves, with app-private last-known-good projections through the shared atomic -disk cache. A missing saved library becomes a recovery panel; a saved library -stays visible when Jellyfin is unreachable. Playback resolves an actual film or -episode and checks Jellyfin before opening upstream's player. No season, -recommendation stub or different person's session is promoted to playback. +`HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up +publish independently of slow or failed library shelves; saved metadata still +paints immediately, but a saved hero offers Details until watch data revalidates. +Home and title Play actions resolve the current Jellyfin item and its current +checkpoint at the click, preserving millisecond positions. Series actions use +live NextUp with resumable episodes enabled; an explicitly opened episode stays +that episode, and Restart deliberately starts that displayed episode at zero. +Played flags do not discard a positive checkpoint for an intentional rewatch. +No season, recommendation stub or different person's session becomes playable. An unresolved person switch guards Home and both owned/upstream library content before mounting any source read with the prior person's Jellyfin session. +Home and title watch reads refresh on entry and foreground. A successful +PlaybackStopped acknowledgement publishes a revision for the captured server/user, +so a return read that beat the stop write is refreshed again after that write. +The acknowledgement also removes that user's persisted library grid snapshots +instead of waiting for their ten-minute TTL; an in-memory revision bypasses any +older snapshot that raced invalidation. Stop-report failures remain logged and +do not publish a successful acknowledgement. External players refresh after a +supported stop result; an external app that returns no position cannot supply a +missing checkpoint. Another client's changes after the final read are outside +that click's snapshot. + Debug variants additionally package the locked fixture and local artwork under `wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them. The explicit fixture provider supplies approved household data and Home states @@ -586,6 +599,13 @@ Selections persist through the same playback repository as the upstream player. Marking a whole series asks first; an explicit episode remains the exact play and watched target even while another season is selected. Nested person/title visits retain their entry's focus and selection state. +Choosing another episode from an episode page replaces only the top navigation +entry, preserving its series/Home parent. Choosing the first episode from a +series, related titles, cast and performer credits still push normal drill-ins. +Down into season chips initially focuses the selected playable season, including +offscreen later seasons; the row scrolls that chip into view and remembers +subsequent deliberate browsing. Saved title watch data cannot enable Play/Resume +or Restart before source revalidation. Hardware and remote Back share the sheet/picker/request-overlay precedence, then pop a pushed title/person from content **or** nav. Initial loading has no content target: Down stays in nav, while Back can still leave the title. A missing rating diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt index eefd5945..1078d8db 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt @@ -69,7 +69,7 @@ class HomeFocus( private fun activate(row: HomeRow) { when (row.kind) { HomeRowKind.Hero -> row.cards.firstOrNull()?.let { title -> - onAction(if (column == 0 && row.heroKind != "pick") HomeAction.Play(title, row.heroKind == "resume") else HomeAction.Details(title)) + onAction(if (column == 0 && row.heroKind != "pick" && row.watchReady) HomeAction.Play(title) else HomeAction.Details(title)) } HomeRowKind.Shelf -> if (column < row.cards.size) onAction(HomeAction.Details(row.cards[column])) else onAction(HomeAction.SeeAll(row)) HomeRowKind.State -> onAction(HomeAction.Retry) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt index eb487f62..8aad42d4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -43,9 +43,10 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List = cards, + val watchReady: Boolean = true, ) { val hasSeeAll: Boolean get() = total > cards.size - val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick") 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 } + val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick" || !watchReady) 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 } val focusable: Boolean get() = !skeleton && count > 0 } @@ -84,7 +85,7 @@ data class HomeSnapshot( /** Home emits integration intents, never owns the upstream player or later-slice pages. */ sealed interface HomeAction { - data class Play(val title: HomeTitle, val resume: Boolean) : HomeAction + data class Play(val title: HomeTitle) : HomeAction data class Details(val title: HomeTitle) : HomeAction data class SeeAll(val row: HomeRow) : HomeAction data object Discover : HomeAction diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt index 103e8818..4630ce75 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt @@ -91,6 +91,7 @@ data class TitlePageData( val episodesStatus: TitleReadStatus = TitleReadStatus.Ready, val requestsAllowed: Boolean = true, val requestFor: String? = null, + val watchReady: Boolean = true, ) /** Effects are handled by the app's authenticated facade and navigation layer. */ @@ -101,7 +102,7 @@ sealed interface TitleAction { data class Watched(val item: TitleItem, val watched: Boolean) : TitleAction data class Trailer(val url: String) : TitleAction data class Person(val person: TitlePerson) : TitleAction - data class OpenTitle(val item: TitleItem) : TitleAction + data class OpenTitle(val item: TitleItem, val episodeSelection: Boolean = false) : TitleAction data class Season(val number: Int) : TitleAction data class Request(val item: TitleItem, val forSomeoneElse: Boolean) : TitleAction data class Hide(val item: TitleItem, val hidden: Boolean) : TitleAction diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index e2deee4a..c683c8e7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -182,7 +182,12 @@ private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) { @Composable private fun TitleSeasons(state: TitleState, active: Boolean) { val focusOwner = LocalTitleFocusOwner.current - androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { + val list = androidx.compose.foundation.lazy.rememberLazyListState() + val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season) + LaunchedEffect(visibleChip, state.data.seasonNumbers) { + if (visibleChip >= 0) list.animateScrollToItem(visibleChip) + } + androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index -> val number = state.data.seasonNumbers[index] val focused = active && state.column == index diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index 2e5da10c..d8a6d560 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -31,6 +31,8 @@ class TitleState( var watched by mutableStateOf(initial.watchTarget.watched) var toast by mutableStateOf(null) private var armedCard: String? = null + private var seasonChosen = false + val requestedSeason: Int? get() = season.takeIf { seasonChosen } private var okHeld = false val target: TitleItem? get() = data.playTarget ?: data.item.takeIf { it.kind == "movie" && it.held } val version: TitleVersion? get() = data.versions.firstOrNull { it.id == versionId } ?: data.versions.firstOrNull() @@ -56,7 +58,7 @@ class TitleState( val pendingAudio = audioIndex != data.selectedAudio val pendingSubtitle = subtitleIndex != data.selectedSubtitle data = value - if (wasLoading || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1 + if (wasLoading || (!seasonChosen && !columns.containsKey("seasons")) || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1 if (!pendingWatched) watched = value.watchTarget.watched if (!pendingVersion) versionId = value.selectedVersion if (!pendingAudio) audioIndex = value.selectedAudio @@ -152,7 +154,8 @@ class TitleState( val next = rows[index] val x = centers[row]?.get(column) val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null - focus(next, (columns[next] ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0))) + val selected = if (next == "seasons") data.seasonNumbers.indexOf(season).takeIf { it >= 0 } else null + focus(next, (columns[next] ?: selected ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0))) } private fun rowSize(id: String): Int = when (id) { @@ -168,15 +171,15 @@ class TitleState( when (row) { "cast" -> data.cast.getOrNull(column)?.let { onAction(TitleAction.Person(it)) } "related" -> data.related.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) } - "episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) } - "seasons" -> { season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) } + "episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it, episodeSelection = true)) } + "seasons" -> { seasonChosen = true; season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) } "header" -> options.getOrNull(column)?.let { activateOption(it.id) } } } private fun activateOption(id: String) { when (id) { - "play", "restart" -> target?.let { onAction(TitleAction.Play(it, id == "restart")) } + "play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) } "version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version } "tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks } "more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo } @@ -260,7 +263,8 @@ fun titleOptions(state: TitleState): List = buildList { return@buildList } if (item.held) { - if (target != null) { + if (!data.watchReady) add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false)) + if (target != null && data.watchReady) { val verb = if (target.resumeSeconds > 0) "Resume" else "Play" add(TitleOption("play", verb + if (target.kind == "episode") " ${target.episodeLabel}" else "", "play", primary = true, icon = false)) if (target.resumeSeconds > 0) add(TitleOption("restart", "Restart", "restart", "play from the beginning")) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt index 01c953b8..a23faa73 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt @@ -36,8 +36,8 @@ class HomeKeysTest { for (key in 0 until keys.length()) press(keys.getString(key)) assertEquals(path.getString("goal"), path.getInt("presses"), keys.length()) when (index) { - 0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play); assertTrue((action as HomeAction.Play).resume) } - 1 -> { assertTrue(action is HomeAction.Play); assertFalse((action as HomeAction.Play).resume); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) } + 0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play) } + 1 -> { assertTrue(action is HomeAction.Play); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) } 2 -> assertEquals("trending", fixture.snapshot("justin").rows[shell.view.row].id) 3 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("trending", row.id); assertEquals(row.cards.size, shell.view.cards[row.id]); assertTrue(row.hasSeeAll) } 4 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("lib-shows", row.id); assertEquals(2, shell.view.cards[row.id]); assertFalse(shell.navFocused) } @@ -170,7 +170,6 @@ class HomeKeysTest { assertEquals(0, shell.view.cards["hero"]) press("OK") val play = action as HomeAction.Play - assertFalse(play.resume) assertEquals("Yu Yu Hakusho", play.title.seriesTitle) }