Integrate native Home and replay full title launch paths

This commit is contained in:
justinzeus 2026-11-05 21:49:34 +01:00
commit fc02ce8646
697 changed files with 2956 additions and 146 deletions

View file

@ -0,0 +1,8 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import org.justinvisser.wolfskin.design.HomeFixture
import org.justinvisser.wolfskin.design.HomeSnapshot
/** Only debug builds may read the locked prototype household projection. */
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = HomeFixture(context).snapshot(person)

View file

@ -0,0 +1,8 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import org.justinvisser.wolfskin.design.HomeSnapshot
/** Release Home always reads the selected Jellyfin identity, never prototype data. */
@Suppress("UNUSED_PARAMETER")
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = null

View file

@ -0,0 +1,29 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.justinvisser.wolfskin.design.HomeLibrary
import org.justinvisser.wolfskin.design.HomeTitle
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
/** A reopened Home cache preserves playable identity and episode/resume state without network reads. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class HomeLibraryCacheTest {
@Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() {
val context = ApplicationProvider.getApplicationContext<Application>()
val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42)
val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43)
val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84))
HomeLibraryCache(context).write("server:person", library, 12345L)
val saved = HomeLibraryCache(context).read("server:person")!!
assertEquals(library, saved.first)
assertEquals(12345L, saved.second)
assertNull(HomeLibraryCache(context).read("server:someone-else"))
}
}

View file

@ -0,0 +1,130 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
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.ui.nav.Destination
import io.mockk.mockk
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.assertSame
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Home opens the complete shared grid and Back restores its original continuation card. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class HomeSeeAllNavigationTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun personalMovieContinuationReachesTheEighteenthTitleAndReturnsToHome() = continuation("pk-m", 5) { grid ->
assertEquals(18, grid.items.size)
repeat(2) { press(Key.DirectionDown) }
repeat(5) { press(Key.DirectionRight) }
rule.onNodeWithText("Bleach the Movie: Fade to Black").assertIsDisplayed()
assertEquals("movie-bleach-the-movie-fade-to-black-2008", grid.focusedTitle?.id)
}
@Test fun horizontallyScrolledContinuationRestoresTheExactHomeViewport() = continuation("trending", 20) { grid ->
press(Key.DirectionDown)
assertEquals(6, grid.index)
rule.onNodeWithTag("library-grid").assertIsDisplayed()
}
private fun continuation(rowId: String, expectedColumn: Int, exerciseGrid: (LibraryGridState) -> Unit) {
rule.mainClock.autoAdvance = false
val snapshot = HomeFixture(rule.activity).snapshot("justin")
val row = snapshot.rows.first { it.id == rowId }
val cardTag = "home-card:$rowId:$expectedColumn"
val navigation = NavigationManager()
val lists = WolfskinSeeAllLists()
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") {
if (it == ShellAction.Pop) navigation.goBack()
}.apply {
pickerVisible = false
view.row = snapshot.rows.indexOf(row)
view.home.focusedKey = row.id
view.cards[row.id] = row.cards.size
}
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists)
val facade = mockk<LibraryReadFacade>()
rule.setContent {
val destination = navigation.backStack.last()
val route = (destination as? Destination.Wolfskin)?.route ?: "home"
LaunchedEffect(destination) { shell.enterDestination(route) }
WolfskinTheme { ShellSurface(shell) { modifier ->
if (destination is Destination.Home) HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
else WolfskinLibraryContent(rule.activity, route, shell, user, "fixture", facade, navigation, lists, modifier)
} }
}
settle()
rule.onNodeWithTag(cardTag).assertIsDisplayed()
val home = shell.view
val track = home.home.tracks.getValue(rowId)
val verticalIndex = home.listState.firstVisibleItemIndex
val verticalOffset = home.listState.firstVisibleItemScrollOffset
val rowOffset = home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset
val horizontalIndex = track.firstVisibleItemIndex
val horizontalOffset = track.firstVisibleItemScrollOffset
press(Key.DirectionCenter)
assertEquals("seeall/justin/$rowId", (navigation.backStack.last() as Destination.Wolfskin).route)
val grid = requireNotNull(lists.get("justin/$rowId"))
try {
rule.onNodeWithTag("library-grid").assertIsDisplayed()
exerciseGrid(grid)
press(Key.Back)
rule.waitForIdle()
assertTrue(navigation.backStack.last() is Destination.Home)
assertEquals(row.id, snapshot.rows[shell.view.row].id)
assertEquals(expectedColumn, shell.view.cards[row.id])
assertSame("Home retains its original route memory", home, shell.view)
assertSame("Home retains its original horizontal state", track, home.home.tracks.getValue(rowId))
assertEquals("Vertical first item", verticalIndex, home.listState.firstVisibleItemIndex)
assertEquals("Vertical pixel offset", verticalOffset, home.listState.firstVisibleItemScrollOffset)
assertEquals("Selected row pixel offset", rowOffset, home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset)
assertEquals("Horizontal first item", horizontalIndex, track.firstVisibleItemIndex)
assertEquals("Horizontal pixel offset", horizontalOffset, track.firstVisibleItemScrollOffset)
rule.onNodeWithTag(cardTag).assertIsDisplayed()
} catch (failure: AssertionError) {
throw AssertionError(
"Expected $rowId:$expectedColumn vertical=$verticalIndex/$verticalOffset row-offset=$rowOffset horizontal=$horizontalIndex/$horizontalOffset; " +
"actual route=${shell.route} row=${home.row} card=${home.cards[rowId]} nav=${shell.navFocused} handler=${shell.contentFocus?.javaClass?.simpleName} grid=${grid.index} " +
"vertical=${home.listState.firstVisibleItemIndex}/${home.listState.firstVisibleItemScrollOffset} horizontal=${track.firstVisibleItemIndex}/${track.firstVisibleItemScrollOffset} " +
"visible-rows=${home.listState.layoutInfo.visibleItemsInfo.map { it.key to it.offset }}",
failure,
)
}
}
private fun press(key: Key) {
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
settle()
}
private fun settle() {
repeat(3) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
rule.mainClock.advanceTimeBy(1_000)
rule.waitForIdle()
}
}

View file

@ -0,0 +1,132 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.input.key.Key
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 org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Full launch paths render the real picker, Home hero/shelves, title and person surfaces. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class TitleLaunchKeysTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: TitleFixture
private lateinit var home: HomeFixture
private lateinit var shell: ShellState
private var title by mutableStateOf<TitleState?>(null)
private var person by mutableStateOf<PersonState?>(null)
private val actions = mutableListOf<TitleAction>()
private val shellActions = mutableListOf<ShellAction>()
private lateinit var paths: List<JSONObject>
@Before fun launch() {
fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
home = HomeFixture(rule.activity)
val source = javaClass.getResourceAsStream("/title-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) }
paths = List(source.length()) { source.getJSONObject(it) }
shell = ShellState(fixture.people, "justin") { action ->
shellActions.add(action)
if (action is ShellAction.Person) { title = null; person = null }
}
rule.setContent { WolfskinTheme {
val performer = person
val page = title
val snapshot = remember(shell.person.key) { home.snapshot(shell.person.key) }
when {
performer != null -> PersonSurface(performer, shell)
page != null -> TitleSurface(page, shell)
else -> ShellSurface(shell) { modifier ->
HomeScreen(shell, snapshot, modifier) { action ->
if (action is HomeAction.Details) openTitle(action.title.id)
}
}
}
} }
}
private fun openTitle(id: String) {
val data = fixture.title(id, shell.person.key)
title = TitleState(data, shell.person, ::act)
person = null
shell.enterDestination(titleRoute(id))
}
private fun act(action: TitleAction) {
actions.add(action)
when (action) {
is TitleAction.Person -> {
val (actor, credits) = fixture.person(action.person.id, shell.person.key)
person = PersonState(actor, credits, ::act)
shell.enterDestination(personRoute(actor.id))
}
is TitleAction.OpenTitle -> openTitle(action.item.id)
else -> Unit
}
}
private fun replay(end: String, expectedPresses: Int) {
val path = paths.single { it.getString("end") == end }
val keys = path.getJSONArray("keys")
assertEquals(expectedPresses, path.getInt("presses"))
assertEquals(expectedPresses, keys.length())
for (index in 0 until keys.length()) {
val key = when (keys.getString(index)) {
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown
"LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight
"OK" -> Key.DirectionCenter
else -> error("Unknown launch replay key")
}
rule.onRoot().performKeyInput { pressKey(key) }
}
}
@Test fun titleToNavTakesFourPressesFromTheRealHomeHero() {
replay("nav", 4)
assertNotNull(title)
assertTrue(shell.navFocused)
assertEquals(shell.currentTab, shell.navIndex)
}
@Test fun switchingPersonFromATitleTakesEightLaunchPresses() {
replay("person-mindel", 8)
assertEquals("mindel", shell.person.key)
assertFalse(shell.pickerVisible)
assertEquals("home", shell.route)
assertTrue(shellActions.last() is ShellAction.Person)
}
@Test fun lockeVersionTwoAndPlayTakesFifteenLaunchPresses() {
replay("play-version-2", 15)
val page = requireNotNull(title)
assertEquals("Locke", page.data.item.name)
assertEquals("v2", page.versionId)
assertNull(page.sheet)
assertEquals("play", page.options[page.column].id)
assertEquals("Locke", (actions.last() as TitleAction.Play).item.name)
}
@Test fun lockeToTomHardyTakesElevenLaunchPresses() {
replay("person-Tom Hardy", 11)
assertEquals("Tom Hardy", requireNotNull(person).person.name)
assertEquals(0, person!!.index)
assertFalse(shell.navFocused)
assertTrue(actions.last() is TitleAction.Person)
}
}

View file

@ -18,11 +18,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotFocused
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
@ -131,6 +133,7 @@ class UpstreamContentFocusTest {
rule.runOnIdle { WolfskinScreenPreference.enabled = true }
rule.mainClock.advanceTimeBy(1500)
rule.waitForIdle()
rule.onNodeWithText("Sign in as Justin to open their Jellyfin library").assertIsDisplayed()
val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg
org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f)

View file

@ -0,0 +1,133 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text
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.ui.nav.Destination
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
/** The Home entry owns its source reads and upstream navigation, never the design module. */
@HiltViewModel
class WolfskinHomeViewModel @Inject constructor(
@param:ApplicationContext private val context: Context,
private val home: HomeReadFacade,
private val cache: HomeLibraryCache,
private val navigation: NavigationManager,
private val seeAll: WolfskinSeeAllLists,
) : ViewModel() {
var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading))
private set
var toast by mutableStateOf<String?>(null)
private set
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return }
val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut))
if (person != user.name.orEmpty().lowercase()) {
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
return
}
val scope = "${server.id}:${user.id}"
val saved = withContext(Dispatchers.IO) { cache.read(scope) }
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation))
try {
val library = withContext(Dispatchers.IO) { home.read(user.id) }
val savedAt = System.currentTimeMillis()
withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) }
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
snapshot = if (saved != null) HomeSnapshot(buildHomeRows(saved.first, continuation), jellyfinAvailable = false, savedAt = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(saved.second)))
else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
if (retry) showToast("Still can’t reach it. The last saved view stays on screen.")
}
}
fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
when (action) {
HomeAction.Discover -> openRoot(shell, "discover")
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person.key, retry = true) }
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else seeAll.open(shell.person.key, action.row.id, action.row.title, action.row.allCards.map { it.libraryTitle() }, navigation)
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
is HomeAction.Play -> viewModelScope.launch {
if (!snapshot.jellyfinAvailable || shell.person.key != user.name.orEmpty().lowercase()) {
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
return@launch
}
try {
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
if (id == null) showToast("Can’t reach Jellyfin to play. Check the network and try again.")
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
}
}
}
}
private fun openRoot(shell: ShellState, route: String) {
shell.enterDestination(route)
shell.focusNav(shell.tabs.indexOf(route))
shell.key(ShellKey.Ok)
}
private fun showToast(message: String) {
toast = message
viewModelScope.launch { delay(WolfskinTokens.Motion.toast.toLong()); if (toast == message) toast = null }
}
}
/** Complete catalogue projections feed the shared See all grid without another source read. */
internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
id = id, title = title, type = type, year = year, runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasons,
poster = poster, played = played, progress = runtimeSeconds?.let { if (it > 0) (resumeSeconds / it).toFloat() else 0f } ?: 0f,
addedAt = addedAt, tmdbId = tmdbId?.toString(),
availability = if (held) "In your library" else otherOwner?.let { "In $it’s library" }.orEmpty(),
badge = if (held) "held" else null,
ratings = ratings.map { rating ->
when (rating.mark) {
"fresh" -> LibraryRating("rtCritics", rating.score, "Fresh")
"rotten" -> LibraryRating("rtCritics", rating.score, "Rotten")
"tomato" -> LibraryRating("rtCritics", rating.score)
"upright" -> LibraryRating("rtAudience", rating.score, "Upright")
"spilled" -> LibraryRating("rtAudience", rating.score, "Spilled")
else -> LibraryRating(rating.mark, rating.score)
}
},
)
/** The shell keeps its focus memory while playback or later-slice screens are pushed above Home. */
@Composable
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) }
Box(modifier) {
HomeScreen(shell, model.snapshot, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
model.toast?.let { message ->
Text(message, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.body, modifier = Modifier.align(Alignment.TopEnd).padding(top = WolfskinTokens.Home.toastTop, end = WolfskinTokens.Layout.safeSide).widthIn(max = WolfskinTokens.Home.toastWidth).background(WolfskinTokens.Color.surface2).padding(WolfskinTokens.Home.toastPadding))
}
}
}

View file

@ -212,8 +212,9 @@ fun WolfskinApp(
ShellSurface(state, handlesContentKeys = upstream == null,
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
when {
upstream != null && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
(upstream != null || destination is Destination.Home) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)

View file

@ -52,7 +52,7 @@ fun WolfskinLibraryContent(
seeAll: WolfskinSeeAllLists,
modifier: Modifier = Modifier,
) {
val view = shell.view
val view = shell.rememberedView(route)
val fixture by produceState<LibraryFixture?>(null, context) {
if (BuildConfig.DEBUG) value = withContext(Dispatchers.IO) {
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
@ -87,7 +87,7 @@ fun WolfskinLibraryContent(
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
}
}
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null)
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null, view = view)
}
val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime")

View file

@ -0,0 +1,66 @@
package org.justinvisser.wolfskin.tv.read
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.justinvisser.wolfskin.conductarr.CacheEntry
import org.justinvisser.wolfskin.conductarr.FileCacheStore
import org.justinvisser.wolfskin.design.HomeLibrary
import org.justinvisser.wolfskin.design.HomeTitle
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
@Singleton
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
private val store = FileCacheStore(File(context.filesDir, "wolfskin-home"))
fun read(scope: String): Pair<HomeLibrary, Long>? {
val entry = store.read(scope, KEY) ?: return null
return try {
val json = JSONObject(entry.body)
val destinations = json.getJSONObject("destinations")
val totals = json.getJSONObject("totals")
HomeLibrary(
decode(json.getJSONArray("resume")), decode(json.getJSONArray("nextUp")), decode(json.getJSONArray("newest")),
destinations.keys().asSequence().associateWith { decode(destinations.getJSONArray(it)) },
totals.keys().asSequence().associateWith { totals.getInt(it) },
) to entry.savedAtMillis
} catch (_: JSONException) { store.remove(scope, KEY); null }
}
fun write(scope: String, library: HomeLibrary, savedAtMillis: Long) {
val json = JSONObject().put("resume", encode(library.resume)).put("nextUp", encode(library.nextUp)).put("newest", encode(library.newest))
.put("destinations", JSONObject().apply { library.destinations.forEach { (key, titles) -> put(key, encode(titles)) } })
.put("totals", JSONObject(library.destinationTotals))
store.write(scope, KEY, CacheEntry(json.toString(), null, savedAtMillis, savedAtMillis, group = "home"))
}
private fun encode(titles: List<HomeTitle>): JSONArray = JSONArray().apply {
titles.forEach { title -> put(JSONObject().apply {
put("id", title.id); put("title", title.title); put("type", title.type); put("year", title.year)
put("runtime", title.runtimeSeconds); put("seasons", title.seasons); put("overview", title.overview)
put("poster", title.poster); put("backdrop", title.backdrop); put("posterOnly", title.posterOnly)
put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode)
put("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt)
put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId)
}) }
}
private fun decode(items: JSONArray): List<HomeTitle> = List(items.length()) { index ->
val item = items.getJSONObject(index)
fun string(key: String): String? = if (item.isNull(key)) null else item.getString(key)
fun number(key: String): Int? = if (item.isNull(key)) null else item.getInt(key)
val genres = item.getJSONArray("genres")
HomeTitle(
item.getString("id"), item.getString("title"), item.getString("type"), number("year"), number("runtime"), number("seasons"), item.getString("overview"),
string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"),
List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"),
)
}
companion object { private const val KEY = "home:jellyfin" }
}

View file

@ -0,0 +1,80 @@
package org.justinvisser.wolfskin.tv.read
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.ImageUrlService
import java.util.UUID
import javax.inject.Inject
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.userViewsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder
import org.justinvisser.wolfskin.design.HomeLibrary
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) {
suspend fun read(userId: UUID): HomeLibrary {
val resume = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items.map(::title)
val next = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items.map(::title)
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
val newest = mutableListOf<HomeTitle>()
val totals = linkedMapOf<String, Int>()
for (library in libraries) {
val key = when {
"anime" in library.name.orEmpty().lowercase() -> "anime"
library.collectionType == CollectionType.MOVIES -> "movies"
library.collectionType == CollectionType.TVSHOWS -> "shows"
else -> continue
}
val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content
destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map(::title))
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))
}
return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, 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
}
private fun title(item: BaseItemDto): HomeTitle {
val wrapped = BaseItem(item)
return HomeTitle(
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
year = item.productionYear, runtimeSeconds = item.runTimeTicks?.div(TICKS_PER_SECOND)?.toInt(), seasons = item.childCount,
overview = item.overview.orEmpty(), poster = images.getItemImageUrl(wrapped, ImageType.PRIMARY, fillWidth = 256, fillHeight = 384),
backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720),
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, tmdbId = item.providerIds?.get("Tmdb")?.toIntOrNull(),
)
}
companion object {
private const val TICKS_PER_SECOND = 10_000_000L
private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT)
}
}