Fix TV episode navigation and live resume freshness

This commit is contained in:
justinzeus 2026-11-06 22:41:16 +01:00
parent 54c23e076a
commit 8e40ac0869
34 changed files with 793 additions and 101 deletions

View file

@ -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
*/

View file

@ -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(

View file

@ -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)

View file

@ -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<Map<String, Long>>(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)) }
}
}

View file

@ -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)
}

View file

@ -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

View file

@ -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<ApiClient>()
val reports = mockk<PlayStateApi>()
every { api.playStateApi } returns reports
val player = mockk<Player> { 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<Unit>()
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<ApiClient>()
val reports = mockk<PlayStateApi>()
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<Player> { 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()
}
}
}

View file

@ -52,8 +52,9 @@ class ConductarrHomeSurfaceTest {
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val home = mockk<HomeReadFacade>()
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<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)

View file

@ -43,11 +43,13 @@ class HomeRecoveryTest {
val started = CompletableDeferred<Unit>()
val response = CompletableDeferred<HomeLibrary>()
val facade = mockk<HomeReadFacade>()
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<Unit>()
val responseA = CompletableDeferred<HomeLibrary>()
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<HomeReadFacade>()
coEvery { facade.read(personA.id) } returns library
coEvery { facade.readWatch(personA.id) } returns library
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
val cache = mockk<HomeLibraryCache>()
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<HomeReadFacade>()
coEvery { facade.read(personA.id) } returns library
coEvery { facade.readWatch(personA.id) } returns library
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))

View file

@ -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")

View file

@ -214,8 +214,9 @@ class PersonSwitchThreadingTest {
every { repository.currentUser } returns user
every { repository.currentServer } returns server
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
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<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)

View file

@ -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)

View file

@ -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) {

View file

@ -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 }

View file

@ -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<TitleReadFacade>()
coEvery { facade.readSaved(any(), any()) } returns null
coEvery { facade.read(any(), any(), any()) } coAnswers {
val id = firstArg<UUID>()
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<ServerRepository>(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<TitleReadFacade>()
coEvery { facade.readSaved(any(), any()) } returns null
coEvery { facade.read(any(), any(), any()) } coAnswers {
TitlePageData(series, first, watchTarget = if (firstArg<UUID>() == 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<ServerRepository>(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()
}
}
}

View file

@ -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 {

View file

@ -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<ApiClient>()
private val library = mockk<UserLibraryApi>()
private val shows = mockk<TvShowsApi>()
private val items = mockk<ItemsApi>()
private val images = mockk<ImageUrlService>(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<ServerRepository> {
every { currentUser } returns mockk { every { id } returns userId }
}
val title = TitleReadFacade(api, images, mockk(), repository, mockk(), mockk<Context>())
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<Unit>()
coEvery { facade.readLibrary(userId) } coAnswers { releaseLibrary.await(); throw java.io.IOException("Library failed") }
val cache = mockk<HomeLibraryCache>(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()
}
}
}

View file

@ -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<ComponentActivity>()
@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<HomeReadFacade>()
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<HomeLibraryCache>(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)
}
}
}

View file

@ -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

View file

@ -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) {

View file

@ -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<Long, Int> {
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
}

View file

@ -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 } }

View file

@ -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<String, MutableList<HomeTitle>>()
val newest = mutableListOf<HomeTitle>()
@ -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<UUID, Int?> = emptyMap()): HomeTitle {

View file

@ -19,13 +19,13 @@ data class LibrarySnapshot(val items: List<LibraryTitle>, 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<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow {
fun read(scope: String, destination: String, force: Boolean = false, invalidatedAtMillis: Long = 0L, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = 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<LibraryTitle> = emptyList()
try {
load().collect { chunk ->

View file

@ -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

View file

@ -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) {

View file

@ -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

View file

@ -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)

View file

@ -43,9 +43,10 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
Spacer(Modifier.height(H.overviewBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
val detailsOnly = row.heroKind == "pick" || !row.watchReady
val primary = if (detailsOnly) "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && column == 0, true, if (detailsOnly) "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
}
}
} else {

View file

@ -67,9 +67,10 @@ data class HomeRow(
val skeleton: Boolean = false,
val calm: Boolean = false,
val allCards: List<HomeTitle> = 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

View file

@ -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

View file

@ -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

View file

@ -31,6 +31,8 @@ class TitleState(
var watched by mutableStateOf(initial.watchTarget.watched)
var toast by mutableStateOf<String?>(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<TitleOption> = 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"))

View file

@ -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)
}