Preserve title data and focus across updates

This commit is contained in:
justinzeus 2026-11-05 17:13:53 +01:00
parent 0c94e8f910
commit 6d7df0fb19
31 changed files with 747 additions and 147 deletions

View file

@ -54,4 +54,17 @@ class ShellRoutingTest {
assertEquals(Destination.Search("name"), resolveUpstreamDestination(Destination.Search("name"), enabled, libraries))
}
}
@Test fun titleRolloutOwnsOnlyFilmSeriesEpisodeAndPerson() {
val id = UUID.fromString("00000000-0000-0000-0000-000000000001")
for (kind in listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE, BaseItemKind.PERSON)) {
val destination = Destination.MediaItem(id, kind)
assertNull(resolveUpstreamDestination(destination, true, libraries))
assertEquals(destination, resolveUpstreamDestination(destination, false, libraries))
}
for (kind in listOf(BaseItemKind.COLLECTION_FOLDER, BaseItemKind.AUDIO, BaseItemKind.MUSIC_ALBUM, BaseItemKind.BOX_SET)) {
val destination = Destination.MediaItem(id, kind)
assertEquals(destination, resolveUpstreamDestination(destination, true, libraries))
}
}
}

View file

@ -15,6 +15,7 @@ import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import org.json.JSONObject
import org.junit.Before
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@ -43,14 +44,17 @@ import java.util.Locale
class TitleCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: TitleFixture
private var previousLocale = Locale.getDefault()
@Before fun fullscreen() {
previousLocale = Locale.getDefault()
Locale.setDefault(Locale.US)
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
}
@After fun restoreLocale() { Locale.setDefault(previousLocale) }
private fun capture(number: Int, title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }, configure: (TitleState, ShellState) -> Unit = { _, _ -> }) {
val data = transform(fixture.title(title, person))
@ -75,7 +79,9 @@ class TitleCaptureTest {
if (data.item.backdrop == null || data.item.isPosterBackdrop) data.item.poster?.let { add(Triple(it, 420, 630)) }
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
data.episodes.forEach { item -> (item.backdrop ?: item.poster)?.let { add(Triple(it, 416, 234)) } }
data.episodes.forEach { item ->
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 416, 234)) }
}
}
return requests.distinctBy { it.first }.associate { (path, width, height) ->
val bitmap = rule.activity.assets.open(path.removePrefix("file:///android_asset/")).use { BitmapFactory.decodeStream(it)!! }
@ -89,7 +95,7 @@ class TitleCaptureTest {
private fun save(number: Int) {
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() }
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "st-${number.toString().padStart(3, '0')}.png").absolutePath)
}

View file

@ -7,6 +7,9 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performMouseInput
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
@ -184,4 +187,68 @@ class TitleKeysTest {
assertEquals(TitleSheet.Version, state.sheet)
assertTrue(effects.none { it is TitleAction.Version })
}
@Test fun episodeWatchedToggleKeepsItsIdentityAndDoesNotAskAboutTheSeries() {
val episode = requireNotNull(fixture.title("Andor", "justin").playTarget)
render(episode.id)
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "watched" }) }
press(Key.DirectionCenter)
assertNull(state.sheet)
val change = effects.single() as TitleAction.Watched
assertEquals(episode.id, change.item.id)
assertEquals(!episode.watched, change.watched)
}
@Test fun aPendingSeasonSkipsItsEpisodeSkeletons() {
render("Andor")
rule.runOnIdle { state.showSeasonLoading() }
press(Key.DirectionDown); press(Key.DirectionDown)
assertEquals("cast", state.row)
rule.runOnIdle { state.restoreSeason() }
press(Key.DirectionUp)
assertEquals("episodes", state.row)
}
@Test fun aFailedOlderVersionWriteCannotUndoTheNewerChoice() {
render()
press(Key.DirectionRight); press(Key.DirectionCenter); press(Key.DirectionDown); press(Key.DirectionCenter)
val first = effects.last() as TitleAction.Version
press(Key.DirectionCenter); press(Key.DirectionUp); press(Key.DirectionCenter)
val second = effects.last() as TitleAction.Version
rule.runOnIdle { state.rejectPlayback(first); state.confirmPlayback(second) }
assertEquals("v1", state.versionId)
press(Key.DirectionCenter); press(Key.DirectionDown); press(Key.DirectionCenter)
val failed = effects.last() as TitleAction.Version
rule.runOnIdle { state.rejectPlayback(failed) }
assertEquals("v1", state.versionId)
}
@Test fun aNewRowUsesTheNearestHorizontalCardBeforeItsOwnRememberedCard() {
render()
repeat(4) { press(Key.DirectionRight) }
val x = rule.onNodeWithTag("title-option-trailer").fetchSemanticsNode().boundsInRoot.center.x
val cast = state.data.cast.take(WolfskinTokens.Card.personSlots)
val nearest = cast.indices.minBy { index ->
kotlin.math.abs(rule.onNodeWithTag("title-cast-${cast[index].id}").fetchSemanticsNode().boundsInRoot.center.x - x)
}
press(Key.DirectionDown)
assertEquals(nearest, state.column)
press(Key.DirectionRight)
val remembered = state.column
press(Key.DirectionUp); press(Key.DirectionDown)
assertEquals(remembered, state.column)
}
@Test fun wheelThenRemoteLandsOnTheFirstVisibleFocusableRow() {
render()
rule.onNodeWithTag("title-page").performMouseInput { scroll(800f) }
val expected = rule.runOnIdle {
shell.view.listState.layoutInfo.visibleItemsInfo.first {
it.key in state.rows && it.offset + it.size > WolfskinTokens.Layout.pinnedLine.value * 2
}.key as String
}
assertNotEquals("header", expected)
press(Key.DirectionDown)
assertEquals(expected, state.row)
}
}

View file

@ -29,7 +29,6 @@ import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
@ -71,7 +70,7 @@ class TitleReadFacadeTest {
every { repository.currentUser } returns null
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
val context = mockk<Context> { every { filesDir } returns directory.root }
facade = TitleReadFacade(api, mockk<ImageUrlService>(), playback, repository, mockk<FavoriteWatchManager>(), context)
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
}
private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE) = BaseItemDto(
@ -89,7 +88,7 @@ class TitleReadFacadeTest {
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
val saved = facade.readSaved(firstId, preferences)!!
val screen = TitleState(saved, ShellPerson("justin", "Justin", "J", false)) {}
assertEquals("mkv".uppercase(), saved.versions.single().container)
assertEquals("MKV", saved.versions.single().container)
assertEquals(3, screen.audio?.index)
assertEquals(listOf(8), saved.versions.single().subtitles.map { it.index })
assertEquals(TitleConnection.SignedOut, saved.connection)
@ -109,6 +108,19 @@ class TitleReadFacadeTest {
assertEquals("A real file", facade.readSaved(firstId, preferences)?.item?.name)
}
@Test fun personBiographyAndContinuousCreditsRemainReadableFromDiskWithoutAService() = runTest {
val actor = BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor", overview = "Actual biography")
coEvery { library.getItem(firstId) } returns successResponse(actor)
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(file(secondId)))
facade.readPerson(firstId, 0)
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
coEvery { items.getItems(any<GetItemsRequest>()) } throws java.io.IOException("offline")
val saved = facade.readPersonSaved(firstId)!!
assertEquals("Actual biography", saved.person.biography)
assertEquals(secondId.toString(), saved.credits.single().id)
assertEquals("A real file", saved.credits.single().name)
}
@Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest {
val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9)
val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1)
@ -124,5 +136,6 @@ class TitleReadFacadeTest {
assertEquals(firstId.toString(), data.watchTarget.id)
assertEquals(listOf(secondId.toString()), data.episodes.map { it.id })
assertEquals(listOf(2), data.seasonNumbers)
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
}
}

View file

@ -1,13 +1,16 @@
package org.justinvisser.wolfskin.tv
import com.github.damontecres.wholphin.ui.nav.Destination
import org.jellyfin.sdk.model.api.BaseItemKind
/** The rollout switch changes only intercepted screens; unrelated destinations always pass through. */
fun resolveUpstreamDestination(destination: Destination, wolfskinEnabled: Boolean, libraries: Map<String, Destination>): Destination? =
when (destination) {
is Destination.Home, is Destination.MediaItem -> if (wolfskinEnabled) null else destination
is Destination.Home -> if (wolfskinEnabled) null else destination
is Destination.MediaItem -> if (wolfskinEnabled && destination.type in TITLE_KINDS) null else destination
is Destination.Wolfskin -> if (!wolfskinEnabled && destination.route in LIBRARY_ROUTES) libraries[destination.route] else null
else -> destination
}
private val LIBRARY_ROUTES = setOf("movies", "shows", "anime")
private val TITLE_KINDS = setOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE, BaseItemKind.PERSON)

View file

@ -20,9 +20,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.justinvisser.wolfskin.design.PersonState
import org.justinvisser.wolfskin.design.PersonSurface
import org.justinvisser.wolfskin.design.ShellPerson
@ -47,6 +49,7 @@ fun personRoute(id: String): String = "person/${Uri.encode(id)}"
class WolfskinTitleViewModel @Inject constructor(
@param:ApplicationContext private val context: Context,
private val facade: TitleReadFacade,
private val fixtureProvider: TitleFixtureProvider,
private val navigation: NavigationManager,
) : ViewModel() {
var title by mutableStateOf<TitleState?>(null)
@ -59,24 +62,32 @@ class WolfskinTitleViewModel @Inject constructor(
private var fixture: TitleFixture? = null
private var personReadPending = false
private var personReadFinished = false
private var loadJob: Job? = null
private val saveMutex = Mutex()
private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) }
/** Fixture reads are exclusively debug assets; real ids always use the authenticated Jellyfin facade. */
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences) {
if (route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
route = destination
loadJob?.cancel()
person = null
fixture = null
personReadPending = false
personReadFinished = false
viewer = selected
preferences = userPreferences
val id = Uri.decode(destination.substringAfter('/'))
title = TitleState(TitlePageData(TitleItem(id, "", "movie"), status = TitleReadStatus.Loading), viewer, ::handle)
viewModelScope.launch {
title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = TitleReadStatus.Loading), viewer, ::handle)
loadJob = viewModelScope.launch {
if (BuildConfig.DEBUG && id.toUuidOrNull() == null) {
fixture = withContext(Dispatchers.IO) { TitleFixture(context.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) }
val source = fixtureProvider.read()
fixture = source
if (destination.startsWith("person/")) {
val (actor, credits) = fixture!!.person(id, viewer.key)
val (actor, credits) = withContext(Dispatchers.IO) { source.person(id, selected.key) }
person = PersonState(actor, credits, ::handle)
} else {
val data = fixture!!.title(id, viewer.key)
val data = withContext(Dispatchers.IO) { source.title(id, selected.key) }
val key = "${viewer.key}:${data.item.id}"
val watchKey = "${viewer.key}:${data.watchTarget.id}"
title = TitleState(data.copy(
@ -93,50 +104,79 @@ class WolfskinTitleViewModel @Inject constructor(
private suspend fun readTitle(id: String) {
val uuid = id.toUuidOrNull() ?: return
facade.readSaved(uuid, preferences)?.let { title?.update(it) }
val current = title ?: return
try {
val data = facade.read(uuid, title?.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences)
title?.update(data)
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(uuid, preferences)?.let { current.update(it) }
val data = facade.read(uuid, current.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences)
current.update(data)
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
title?.let { current ->
if (current.data.status == TitleReadStatus.Ready) {
current.toast = "Still can’t reach it. The last saved view stays on screen."
} else current.update(current.data.copy(status = TitleReadStatus.Unavailable))
}
if (current.data.status == TitleReadStatus.Ready) {
if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason()
current.toast = "Still can’t reach it. The last saved view stays on screen."
} else current.update(current.data.copy(status = TitleReadStatus.Unavailable))
}
}
private suspend fun readPerson(id: String) {
if (personReadPending || personReadFinished) return
val uuid = id.toUuidOrNull() ?: return
val owner = viewer.key
personReadPending = true
try {
val (actor, credits) = facade.readPerson(uuid, person?.credits?.size ?: 0)
if (person == null) facade.readPersonSaved(uuid)?.let { saved ->
if (owner == viewer.key) person = newPerson(saved.person, saved.credits).apply { savedAt = saved.savedAt }
}
val start = if (person?.savedAt != null) 0 else person?.credits?.size ?: 0
val result = facade.readPerson(uuid, start)
if (owner != viewer.key) return
val current = person
if (current == null) person = PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(id) } }
else current.credits = (current.credits + credits).distinctBy { it.id }
personReadFinished = credits.size < 200
val credits = if (start == 0) result.credits else (current?.credits.orEmpty() + result.credits).distinctBy { it.id }
if (current == null) person = newPerson(result.person, credits) else current.update(result.person, credits)
personReadFinished = result.credits.size < 200
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable))
} finally { personReadPending = false }
if (owner == viewer.key) {
person?.toast = "Still can’t reach it. The last saved view stays on screen."
if (person == null) title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable))
}
} finally { if (owner == viewer.key) personReadPending = false }
}
private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List<TitleItem>) =
PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } }
private fun handle(action: TitleAction) {
when (action) {
TitleAction.Pop -> navigation.goBack()
is TitleAction.Person -> navigation.navigateTo(Destination.Wolfskin(personRoute(action.person.id)))
is TitleAction.OpenTitle -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.item.id)))
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)
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 -> viewModelScope.launch {
if (fixture == null) readTitle(Uri.decode(route.substringAfter('/')))
is TitleAction.Season -> {
loadJob?.cancel()
loadJob = viewModelScope.launch {
val current = title ?: return@launch
current.showSeasonLoading()
val source = fixture
if (source == null) readTitle(Uri.decode(route.substringAfter('/')))
else {
val data = withContext(Dispatchers.IO) { source.title(Uri.decode(route.substringAfter('/')), viewer.key, action.number) }
current.update(data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex,
selectedSubtitle = current.subtitleIndex, watchTarget = current.data.watchTarget.copy(watched = current.watched), hidden = current.data.hidden))
}
}
}
TitleAction.Retry -> viewModelScope.launch {
if (fixture == null) readTitle(Uri.decode(route.substringAfter('/')))
if (fixture == null) {
if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/'))) else readTitle(Uri.decode(route.substringAfter('/')))
}
else title?.toast = "Still can’t reach it. The last saved view stays on screen."
}
is TitleAction.Request, is TitleAction.Requests -> title?.toast = "Requests need this TV to be signed in to Conductarr."
@ -158,6 +198,7 @@ class WolfskinTitleViewModel @Inject constructor(
}
private fun savePlayback(action: TitleAction) {
val current = title ?: return
if (fixture != null) {
val edits = fixtureSelections.edit()
when (action) {
@ -170,14 +211,17 @@ class WolfskinTitleViewModel @Inject constructor(
else -> Unit
}
edits.apply()
current.confirmPlayback(action)
return
}
viewModelScope.launch {
try { facade.apply(action) }
catch (exception: Exception) {
if (exception is CancellationException) throw exception
if (action is TitleAction.Watched) title?.restoreWatched(!action.watched)
title?.toast = "Couldn’t save that change. Try again."
saveMutex.withLock {
try { facade.apply(action); current.confirmPlayback(action) }
catch (exception: Exception) {
if (exception is CancellationException) throw exception
current.rejectPlayback(action)
current.toast = "Couldn’t save that change. Try again."
}
}
}
}

View file

@ -1,5 +1,12 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import com.github.damontecres.wholphin.BuildConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
import org.json.JSONArray
import org.json.JSONObject
import org.justinvisser.wolfskin.design.ShellPerson
@ -22,7 +29,7 @@ class TitleFixture(private val fixture: JSONObject) {
private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy { key(it) }
/** Each viewer gets their own ownership, resume target and episode watch identity. */
fun title(id: String, person: String): TitlePageData {
fun title(id: String, person: String, season: Int? = null): TitlePageData {
val profile = profiles.first { it.getString("key") == person }
val library = listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() }
val episodes = listOf("resume", "nextUp").flatMap { profile.getJSONArray(it).objects() }
@ -34,6 +41,7 @@ class TitleFixture(private val fixture: JSONObject) {
val play = selectedEpisode ?: episodes.firstOrNull { it.optString("seriesTitle") == container.getString("title") }
?: own?.takeIf { it.optDouble("progress") > 0 }
val target = play?.let { item(it, person) } ?: item.takeIf { it.held && it.kind == "movie" }
val selectedSeason = season ?: play?.optInt("seasonNumber")?.takeIf { it > 0 } ?: 1
val related = (pool + library).filter { key(it) != key(container) && overlap(it, container) > 0 }
.sortedByDescending { overlap(it, container) }.distinctBy { key(it) }.take(16).map { item(it, person) }
val castMembers = cast.optJSONArray(key(container))?.objects().orEmpty().map { member ->
@ -42,8 +50,8 @@ class TitleFixture(private val fixture: JSONObject) {
return TitlePageData(item, play?.let { item(it, person) }, selectedEpisode?.let { item(it, person) } ?: item,
versions = target?.let { versions(it) }.orEmpty(),
trailerUrl = if (item.held && key(container) in TRAILERS) "fixture:trailer" else null,
cast = castMembers, related = related, episodes = episodes.filter { it.optString("seriesTitle") == container.getString("title") }.map { item(it, person) },
connection = TitleConnection.SignedIn, pick = container.optString("source") == "materialized_personal_recommendation")
cast = castMembers, related = related, episodes = episodes.filter { it.optString("seriesTitle") == container.getString("title") && it.optInt("seasonNumber") == selectedSeason }.map { item(it, person) },
connection = TitleConnection.SignedIn, pick = container.optString("source") == "materialized_personal_recommendation", selectedSeason = selectedSeason)
}
/** Only real household credits are retained; prototype filler is never promoted to filmography. */
@ -121,4 +129,16 @@ class TitleFixture(private val fixture: JSONObject) {
}
}
/** Every title entry shares one parsed, read-only debug fixture; release callers cannot load it. */
@Singleton
class TitleFixtureProvider @Inject constructor(@param:ApplicationContext private val context: Context) {
private val fixture by lazy {
TitleFixture(context.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
}
suspend fun read(): TitleFixture = withContext(Dispatchers.IO) {
check(BuildConfig.DEBUG) { "Title fixtures are debug-only" }
fixture
}
}
private fun JSONArray.objects(): List<JSONObject> = List(length()) { getJSONObject(it) }

View file

@ -177,7 +177,7 @@ fun WolfskinApp(
val route = when (destination) {
is Destination.Home -> "home"
is Destination.Wolfskin -> destination.route
is Destination.MediaItem -> titleRoute(destination.itemId.toString())
is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString())
else -> "pushed"
}
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }

View file

@ -38,6 +38,8 @@ import java.util.Locale
import java.util.UUID
import javax.inject.Inject
data class PersonRead(val person: TitlePerson, val credits: List<TitleItem>, val savedAt: String? = null)
/** Jellyfin SDK changes are confined to this boundary; title UI sees immutable screen data only. */
class TitleReadFacade @Inject constructor(
private val api: ApiClient,
@ -59,23 +61,23 @@ 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 = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified())),
trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() })
.copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() })
} catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null }
}
/** An episode opens its series with that exact episode as both play and watched target. */
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
val file = cacheFile(id)
val opened = api.userLibraryApi.getItem(id).content
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content else opened
val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList()
val selectedNumber = season ?: opened.parentIndexNumber ?: 1
val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
}
val selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber
val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull()
val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = fields)).content.items.orEmpty() }.orEmpty()
val target = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }
}
val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }
val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id }
val numbers = seasons.mapNotNull { it.indexNumber }
val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences)
@ -84,7 +86,6 @@ class TitleReadFacade @Inject constructor(
val saved = JSONObject().put("items", json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows))
.put("seasonNumbers", org.json.JSONArray(numbers)).put("selectedSeason", selected?.indexNumber).put("watch", opened.id.toString())
.put("target", target?.id?.toString().orEmpty()).put("trailer", trailer.orEmpty())
val file = cacheFile(id)
file.parentFile?.mkdirs()
try { file.writeText(saved.toString()) } catch (_: java.io.IOException) { /* Playback remains available when the disk cannot save a copy. */ }
result.copy(trailerUrl = trailer)
@ -110,15 +111,38 @@ class TitleReadFacade @Inject constructor(
Unit
}
/** A person keeps the same server/user-scoped last good copy as a title. */
suspend fun readPersonSaved(id: UUID): PersonRead? = withContext(Dispatchers.IO) {
val file = personCacheFile(id)
if (!file.exists()) return@withContext null
try {
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), file.readText())
personData(id, rows.first(), rows.drop(1)).copy(savedAt = savedTime(file))
} catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null }
}
/** Held performer credits are read continuously; a signed-out TV never falls back to Seerr. */
suspend fun readPerson(id: UUID, start: Int): Pair<TitlePerson, List<TitleItem>> = withContext(Dispatchers.IO) {
val person = api.userLibraryApi.getItem(id).content
suspend fun readPerson(id: UUID, start: Int): PersonRead = withContext(Dispatchers.IO) {
val file = personCacheFile(id)
val previous = if (start > 0 && file.exists()) {
try { json.decodeFromString(ListSerializer(BaseItemDto.serializer()), file.readText()) }
catch (_: java.io.IOException) { emptyList() } catch (_: IllegalArgumentException) { emptyList() }
} else emptyList()
val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content
val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200,
sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty()
TitlePerson(id.toString(), person.name.orEmpty(), portrait = images.getItemImageUrl(id, ImageType.PRIMARY, maxWidth = 240, maxHeight = 240), biography = person.overview.orEmpty()) to credits.map { mapItem(it) }
val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id }
file.parentFile?.mkdirs()
try { file.writeText(json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) }
catch (_: java.io.IOException) { /* The readable grid does not disappear when saving fails. */ }
personData(id, person, credits)
}
private fun personData(id: UUID, person: BaseItemDto, credits: List<BaseItemDto>): PersonRead =
PersonRead(TitlePerson(id.toString(), person.name.orEmpty(), portrait = images.getItemImageUrl(id, ImageType.PRIMARY, maxWidth = 240, maxHeight = 240), biography = person.overview.orEmpty()),
credits.map { credit -> mapItem(credit).copy(character = credit.people.orEmpty().firstOrNull { it.id == id }?.role) })
private suspend fun build(rows: List<BaseItemDto>, seasonNumbers: List<Int>, selectedSeason: Int?, watchId: String, targetId: String?, preferences: UserPreferences): TitlePageData {
val item = rows.first()
val target = rows.firstOrNull { it.id.toString() == targetId }
@ -139,7 +163,7 @@ class TitleReadFacade @Inject constructor(
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
},
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it) },
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers)
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
}
private fun mapItem(item: BaseItemDto): TitleItem = TitleItem(
@ -152,4 +176,6 @@ class TitleReadFacade @Inject constructor(
)
private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json")
private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json")
private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified()))
}