Build the Wolfskin Home feed
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||
|
||||
/** Home's continuation targets are explicit seams for the Discover and See all screen slices. */
|
||||
@Composable
|
||||
fun HomeContinuationTarget(route: String, modifier: Modifier = Modifier) {
|
||||
Column(modifier.offset(WolfskinTokens.Layout.safeSide, WolfskinTokens.Layout.detailCopyTop), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Layout.buttonGap)) {
|
||||
Text(if (route == "discover") "Discover" else "See all", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
Text(if (route == "discover") "Discover navigation target — content belongs to the Discover slice" else "See all navigation target — grid belongs to the Libraries and See all slice", color = WolfskinTokens.Color.accentText, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
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,
|
||||
) : 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 navigation.navigateTo(Destination.Wolfskin("see-all:${action.row.id}"))
|
||||
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin("title:${action.title.type}:${action.title.tmdbId ?: 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 }
|
||||
}
|
||||
}
|
||||
|
||||
/** 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -171,6 +171,7 @@ fun WolfskinApp(
|
|||
when {
|
||||
upstream != null && 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)
|
||||
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
||||
else -> ShellPlaceholder(route, contentModifier)
|
||||
}
|
||||
|
|
@ -186,5 +187,9 @@ fun WolfskinApp(
|
|||
/** The single registered destination branch delegates Wolfskin-only screens back to the shell. */
|
||||
@Composable
|
||||
fun WolfskinDestinationContent(route: String, modifier: Modifier = Modifier) {
|
||||
if (route == "settings") SettingsList(LocalShellState.current, modifier) else ShellPlaceholder(route, modifier)
|
||||
when {
|
||||
route == "settings" -> SettingsList(LocalShellState.current, modifier)
|
||||
route == "discover" || route.startsWith("see-all:") -> HomeContinuationTarget(route, modifier)
|
||||
else -> ShellPlaceholder(route, modifier)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -59,8 +59,9 @@ cover the Wolfskin nav.
|
|||
The independent `:wolfskin-design` module owns the shell components, fixed person
|
||||
colours, focus policy, per-person root-view memory, palette and motion. Its
|
||||
`tokens.json` is the copied approved TV-spec source, retaining the locked prototype
|
||||
SHA and provenance. `shell-tokens.json` adds shell measurements from that same
|
||||
prototype. Generate the committed Kotlin, CSS and checksum manifest together:
|
||||
SHA and provenance. `shell-tokens.json` and `home-tokens.json` add measured shell
|
||||
and Home geometry from that same prototype. Generate the committed Kotlin, CSS
|
||||
and checksum manifest together:
|
||||
|
||||
```sh
|
||||
python3 wolfskin-design/generate_tokens.py
|
||||
|
|
@ -77,7 +78,7 @@ Settings groups hand off to existing upstream pages; the Requests entry explicit
|
|||
has no request data yet. **Advanced settings → Wolfskin screens** is the persistent
|
||||
rollout switch: turning it off renders upstream Home and library destinations,
|
||||
while retaining Wolfskin's nav, picker and Settings list. Turning it on renders
|
||||
the labelled placeholders until the corresponding screen slices ship.
|
||||
Wolfskin Home; later-slice screens remain explicitly labelled navigation targets.
|
||||
|
||||
Debug people, tabs and initials come from the shell-only projection of the locked
|
||||
prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources
|
||||
|
|
@ -104,6 +105,51 @@ artifact provider and AAPT2. It checks all string, plural and array configuratio
|
|||
rejecting user-visible upstream branding while preserving the upstream legal
|
||||
attribution and project URL. The check does not depend on incremental merge files.
|
||||
|
||||
## Wolfskin Home
|
||||
|
||||
Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero,
|
||||
Jellyfin shelves, per-destination "Your …" shelves and the Discover continuation.
|
||||
The final action opens Discover at its top. Discover (`discover`) and See all
|
||||
(`see-all:{row-id}`) are named integration targets, not implementations of those
|
||||
later screens. Title navigation remains the shell's later-slice target.
|
||||
|
||||
All Home components and immutable screen models live in `:wolfskin-design`.
|
||||
The shell's remembered view owns vertical position, each row's card and the
|
||||
bounded six-hour ratings session cache. Back preserves the viewport; returning
|
||||
to Home does not re-run focus-follow over a manually scrolled saved view. The
|
||||
next arrow re-enters visible content when scrolling moved focus off-screen.
|
||||
Pointing changes focus without activation, cards activate on key-up, and held
|
||||
OK events cannot activate twice.
|
||||
|
||||
`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release
|
||||
variants read the selected user's Resume, Next up, newest titles and destination
|
||||
shelves, with app-private last-known-good projections through the shared atomic
|
||||
disk cache. A missing saved library becomes a recovery panel; a saved library
|
||||
stays visible when Jellyfin is unreachable. Playback resolves an actual film or
|
||||
episode and checks Jellyfin before opening upstream's player. No season,
|
||||
recommendation stub or different person's session is promoted to playback.
|
||||
|
||||
Debug variants additionally package the locked fixture and local artwork under
|
||||
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
|
||||
The fixture provider supplies the approved household data and Home states without
|
||||
network reads in tests. Conductarr sign-in is not implemented here: real signed-out
|
||||
Home hides every rating and shows "Discover needs sign-in". No Seerr fallback is
|
||||
used. Catalogue data and ratings in debug captures are fixture-backed.
|
||||
|
||||
Home images request exact slot sizes, use RGB565 for posters, disable crossfades,
|
||||
share a 64 MiB memory/128 MiB disk cache, limit image work to four reads and prefetch
|
||||
the next shelf. Rating plates use the TV locale, start at 14 sp, shrink to 12 sp
|
||||
and drop trailing entries rather than wrap. Their uncached read gate is 350 ms;
|
||||
cached focus returns without that delay.
|
||||
|
||||
`make test` includes `HomeCaptureTest` (23 native acceptance states) and the
|
||||
data-driven paths in `wolfskin-home-presses.json`: play next 2, resume 3, Discover
|
||||
4, deep-row Back/Down 9, Trending 13 and Trending's See all card 33 presses.
|
||||
Home captures are written to `build/wolfskin-captures/home/`. Compare those images
|
||||
side by side with the frozen spec captures; Compose Roboto/JPEG rasterisation,
|
||||
the approved button-scale consolidation and removal of prototype sample pills
|
||||
are recorded differences, not a claim of browser pixel equality.
|
||||
|
||||
## Toolchain and disk use
|
||||
|
||||
`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ val cacheRobolectricSdk by tasks.registering(Copy::class) {
|
|||
}
|
||||
|
||||
val checkTokenDrift by tasks.registering {
|
||||
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
|
||||
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "home-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
|
||||
doLast {
|
||||
file("tokens.sha256").readLines().forEach { line ->
|
||||
val (expected, name) = line.split(" ", limit = 2)
|
||||
|
|
|
|||
|
|
@ -100,9 +100,10 @@ def main() -> None:
|
|||
arguments = parser.parse_args()
|
||||
tokens = json.loads((ROOT / "tokens.json").read_text())
|
||||
tokens.update(json.loads((ROOT / "shell-tokens.json").read_text()))
|
||||
tokens.update(json.loads((ROOT / "home-tokens.json").read_text()))
|
||||
kotlin, css = render_tokens(tokens)
|
||||
outputs = {str(KOTLIN): kotlin, "tokens.css": css}
|
||||
inputs = ["tokens.json", "shell-tokens.json", "generate_tokens.py"]
|
||||
inputs = ["tokens.json", "shell-tokens.json", "home-tokens.json", "generate_tokens.py"]
|
||||
checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs}
|
||||
checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()})
|
||||
outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items()))
|
||||
|
|
|
|||
322
wolfskin-design/home-tokens.json
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
{
|
||||
"home": {
|
||||
"kickerGap": {
|
||||
"$type": "dimension",
|
||||
"px": 12,
|
||||
"dp": 6.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"kickerBottom": {
|
||||
"$type": "dimension",
|
||||
"px": 10,
|
||||
"dp": 5.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"titleBottom": {
|
||||
"$type": "dimension",
|
||||
"px": 16,
|
||||
"dp": 8.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"factsHeight": {
|
||||
"$type": "dimension",
|
||||
"px": 38,
|
||||
"dp": 19.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"factsBottom": {
|
||||
"$type": "dimension",
|
||||
"px": 14,
|
||||
"dp": 7.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"factsGap": {
|
||||
"$type": "dimension",
|
||||
"px": 22,
|
||||
"dp": 11.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"overviewBottom": {
|
||||
"$type": "dimension",
|
||||
"px": 30,
|
||||
"dp": 15.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"cardPadding": {
|
||||
"$type": "dimension",
|
||||
"px": 22,
|
||||
"dp": 11.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"cardOverlayTop": {
|
||||
"$type": "dimension",
|
||||
"px": 54,
|
||||
"dp": 27.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"cardOverlayBottom": {
|
||||
"$type": "dimension",
|
||||
"px": 18,
|
||||
"dp": 9.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"sectionTop": {
|
||||
"$type": "dimension",
|
||||
"px": 40,
|
||||
"dp": 20.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"sectionAvatar": {
|
||||
"$type": "dimension",
|
||||
"px": 56,
|
||||
"dp": 28.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"sectionGap": {
|
||||
"$type": "dimension",
|
||||
"px": 22,
|
||||
"dp": 11.0,
|
||||
"source": "locked index.html:180"
|
||||
},
|
||||
"stateTextBottom": {
|
||||
"$type": "dimension",
|
||||
"px": 24,
|
||||
"dp": 12.0,
|
||||
"source": "locked index.html:198"
|
||||
},
|
||||
"stateTop": {
|
||||
"$type": "dimension",
|
||||
"px": 20,
|
||||
"dp": 10.0,
|
||||
"source": "locked index.html:194"
|
||||
},
|
||||
"statePaddingX": {
|
||||
"$type": "dimension",
|
||||
"px": 40,
|
||||
"dp": 20.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"statePaddingY": {
|
||||
"$type": "dimension",
|
||||
"px": 34,
|
||||
"dp": 17.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"stateGap": {
|
||||
"$type": "dimension",
|
||||
"px": 8,
|
||||
"dp": 4.0,
|
||||
"source": "locked index.html:195"
|
||||
},
|
||||
"stateIcon": {
|
||||
"$type": "dimension",
|
||||
"px": 36,
|
||||
"dp": 18.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"captionWidth": {
|
||||
"$type": "dimension",
|
||||
"px": 640,
|
||||
"dp": 320.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"plateTop": {
|
||||
"$type": "dimension",
|
||||
"px": 14,
|
||||
"dp": 7.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"plateSide": {
|
||||
"$type": "dimension",
|
||||
"px": 16,
|
||||
"dp": 8.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"plateFade": {
|
||||
"$type": "dimension",
|
||||
"px": 46,
|
||||
"dp": 23.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"ratingGap": {
|
||||
"$type": "dimension",
|
||||
"px": 20,
|
||||
"dp": 10.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"ratingMarkGap": {
|
||||
"$type": "dimension",
|
||||
"px": 8,
|
||||
"dp": 4.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"heroSkeletonWidth": {
|
||||
"$type": "dimension",
|
||||
"px": 640,
|
||||
"dp": 320.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"heroSkeletonHeight": {
|
||||
"$type": "dimension",
|
||||
"px": 84,
|
||||
"dp": 42.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"heroSkeletonFactsWidth": {
|
||||
"$type": "dimension",
|
||||
"px": 460,
|
||||
"dp": 230.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"heroSkeletonFactsHeight": {
|
||||
"$type": "dimension",
|
||||
"px": 34,
|
||||
"dp": 17.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"heroSkeletonButtonWidth": {
|
||||
"$type": "dimension",
|
||||
"px": 260,
|
||||
"dp": 130.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"heroSkeletonGap": {
|
||||
"$type": "dimension",
|
||||
"px": 22,
|
||||
"dp": 11.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"heroSkeletonButtonGap": {
|
||||
"$type": "dimension",
|
||||
"px": 40,
|
||||
"dp": 20.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"artGlyph": {
|
||||
"$type": "dimension",
|
||||
"px": 44,
|
||||
"dp": 22.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"cardSheetLeft": {
|
||||
"$type": "dimension",
|
||||
"px": 22,
|
||||
"dp": 11.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"cardSheetTop": {
|
||||
"$type": "dimension",
|
||||
"px": 18,
|
||||
"dp": 9.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"cardSheetInset": {
|
||||
"$type": "dimension",
|
||||
"px": 36,
|
||||
"dp": 18.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"heroSheetRight": {
|
||||
"$type": "dimension",
|
||||
"px": 180,
|
||||
"dp": 90.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"heroSheetTop": {
|
||||
"$type": "dimension",
|
||||
"px": 150,
|
||||
"dp": 75.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"cardBlur": {
|
||||
"$type": "dimension",
|
||||
"px": 26,
|
||||
"dp": 13.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"heroBlur": {
|
||||
"$type": "dimension",
|
||||
"px": 60,
|
||||
"dp": 30.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"progressInset": {
|
||||
"$type": "dimension",
|
||||
"px": 18,
|
||||
"dp": 9.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"progressBottom": {
|
||||
"$type": "dimension",
|
||||
"px": 10,
|
||||
"dp": 5.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"endTop": {
|
||||
"$type": "dimension",
|
||||
"px": 40,
|
||||
"dp": 20.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"frameBottom": {
|
||||
"$type": "dimension",
|
||||
"px": 80,
|
||||
"dp": 40.0,
|
||||
"source": "locked index.html:1042"
|
||||
},
|
||||
"seeAllGap": {
|
||||
"$type": "dimension",
|
||||
"px": 8,
|
||||
"dp": 4.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"seeAllIcon": {
|
||||
"$type": "dimension",
|
||||
"px": 48,
|
||||
"dp": 24.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"toastPadding": {
|
||||
"$type": "dimension",
|
||||
"px": 28,
|
||||
"dp": 14.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"toastTop": {
|
||||
"$type": "dimension",
|
||||
"px": 150,
|
||||
"dp": 75.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"toastWidth": {
|
||||
"$type": "dimension",
|
||||
"px": 760,
|
||||
"dp": 380.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"cardWashInset": {
|
||||
"$type": "dimension",
|
||||
"px": 30,
|
||||
"dp": 15.0,
|
||||
"source": "locked index.html:129"
|
||||
},
|
||||
"heroWashInset": {
|
||||
"$type": "dimension",
|
||||
"px": 80,
|
||||
"dp": 40.0,
|
||||
"source": "locked index.html:96"
|
||||
},
|
||||
"noteTop": {
|
||||
"$type": "dimension",
|
||||
"px": 14,
|
||||
"dp": 7.0,
|
||||
"source": "locked index.html:191"
|
||||
},
|
||||
"noteGap": {
|
||||
"$type": "dimension",
|
||||
"px": 16,
|
||||
"dp": 8.0,
|
||||
"source": "locked index.html:191"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 28 KiB |