Preserve title data and focus across updates
This commit is contained in:
parent
0c94e8f910
commit
6d7df0fb19
31 changed files with 747 additions and 147 deletions
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue