Preserve title data and focus across updates

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -86,6 +86,8 @@ def render_tokens(tokens: dict) -> tuple[str, str]:
elif isinstance(token.get("dp"), dict):
for suffix, dimension in token["dp"].items():
lines.append(f" val {name}{suffix[0].upper() + suffix[1:]} = {number(dimension)}.dp")
if kind == "card":
lines.append(f" const val {name}Slots = {token['fullSlotsPerRow']}")
elif group_name == "image" and isinstance(token.get("px"), list):
for suffix, dimension in zip(("Width", "Height"), token["px"]):
lines.append(f" const val {name}{suffix} = {int(dimension)}")

View file

@ -4,6 +4,11 @@ import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.remember
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@ -15,8 +20,14 @@ import androidx.tv.material3.Text
@Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) {
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "caption-fade")
val parts = remember(facts) { facts.split(" · ") }
Column(modifier.graphicsLayer { this.alpha = alpha }) {
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(facts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row(Modifier.fillMaxWidth().clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Caption.factsGap)) {
parts.forEachIndexed { index, part ->
if (index > 0) Text("·", color = WolfskinTokens.Color.muted.copy(alpha = WolfskinTokens.Caption.separatorOpacity), style = WolfskinTokens.Type.caption)
Text(part, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}

View file

@ -7,6 +7,11 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
@ -30,6 +35,7 @@ import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
@ -50,7 +56,7 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
/** There is one blob, including when focus wraps around the nav avatar. */
@Composable
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier, savedAt: String? = null) {
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) }
val index = if (state.navFocused) state.navIndex else state.currentTab
@ -94,6 +100,15 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
}
}
}
if (savedAt != null) {
Row(Modifier.align(Alignment.TopEnd)
.offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2)
.height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button))
.padding(horizontal = V.side), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph("wifi", C.warn, Modifier.size(V.icon))
Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption)
}
}
val avatar = boxes.last()
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
Modifier.offset(avatar.left, avatar.top)

View file

@ -1,5 +1,6 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -19,6 +20,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -36,8 +39,16 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** Filmography retains a real credit key and requests more credits before focus reaches the tail. */
class PersonState(val person: TitlePerson, initialCredits: List<TitleItem>, private val onAction: (TitleAction) -> Unit, private val onReadMore: () -> Unit = {}) {
class PersonState(initialPerson: TitlePerson, initialCredits: List<TitleItem>, private val onAction: (TitleAction) -> Unit, private val onReadMore: () -> Unit = {}) {
var person by mutableStateOf(initialPerson)
private set
var credits by mutableStateOf(initialCredits)
var savedAt by mutableStateOf<String?>(null)
var toast by mutableStateOf<String?>(null)
fun update(actor: TitlePerson, titles: List<TitleItem>, saved: String? = null) {
person = actor; credits = titles; savedAt = saved
index = index.coerceAtMost((credits.size - 1).coerceAtLeast(0))
}
var index by mutableStateOf(0)
private var armed: String? = null
@ -46,7 +57,11 @@ class PersonState(val person: TitlePerson, initialCredits: List<TitleItem>, priv
/** The first grid row exits upward into the existing shell nav. */
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
if (credits.isEmpty()) { if (down && key == ShellKey.Back) onAction(TitleAction.Pop); return true }
if (credits.isEmpty()) {
if (down && key == ShellKey.Back) onAction(TitleAction.Pop)
else if (down && key == ShellKey.Up) shell.focusNav(shell.currentTab)
return true
}
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
if (key == ShellKey.Ok) {
if (repeat > 0) return true
@ -72,8 +87,11 @@ class PersonState(val person: TitlePerson, initialCredits: List<TitleItem>, priv
@Composable
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
ShellSurface(shell, modifier, handlesContentKeys = false) { PersonPage(state, shell, it) }
Box(modifier.fillMaxSize().background(C.bg)) {
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
ShellSurface(shell, handlesContentKeys = false, savedAt = state.savedAt) { PersonPage(state, shell, it) }
}
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
}
}
@ -81,13 +99,32 @@ fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Mo
private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
val density = LocalDensity.current
val list = shell.view.listState
var wheelPending by remember { mutableStateOf(false) }
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
LaunchedEffect(state.index / columns, shell.navFocused) {
if (shell.navFocused || state.index < columns) list.animateScrollToItem(0)
else list.animateScrollToItem(2 + state.index / columns, -with(density) { L.pinnedLine.roundToPx() })
val target = if (shell.navFocused || state.index < columns) 0f
else with(density) { (L.row_phead + L.row_gh + D.gridHeight * (state.index / columns) - L.pinnedLine).toPx() }
val first = list.firstVisibleItemIndex
val before = when (first) {
0 -> WolfskinTokens.Shell.zero
1 -> L.row_phead
else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2)
}
val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset
if (target != current) list.animateScrollBy(target - current, titleFrameScrollSpec)
}
Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" }
.onPreviewKeyEvent { event -> readShellKey(event)?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false }.focusable()) {
.titleWheel { wheelPending = true; shell.navFocused = false }
.onPreviewKeyEvent { event ->
val key = readShellKey(event)
if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) {
val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index >= 2 && it.key != "end-padding" && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() } }
state.index = ((visible?.index?.minus(2) ?: 0) * columns).coerceAtMost((state.credits.size - 1).coerceAtLeast(0))
wheelPending = false
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
}
key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false
}.focusable()) {
LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
item(key = "person-header", contentType = "header") {
Box(Modifier.height(L.row_phead)) {

View file

@ -58,6 +58,7 @@ fun ShellSurface(
modifier: Modifier = Modifier,
handlesContentKeys: Boolean = true,
settingsOpenRequests: Int? = null,
savedAt: String? = null,
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
) {
val focus = remember { FocusRequester() }
@ -86,7 +87,7 @@ fun ShellSurface(
WhosWatching(state)
} else {
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2, savedAt = savedAt)
if (state.requestsVisible) {
Box(Modifier.fillMaxSize().background(C.sheetScrim)) {
Column(Modifier.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@ -19,7 +20,7 @@ import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.toArgb
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@ -30,9 +31,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.tv.material3.Text
import coil3.SingletonImageLoader
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.allowRgb565
import coil3.request.crossfade
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
@ -48,7 +46,7 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo
else if (path != null) {
val context = LocalContext.current
val request = remember(context, path, width, height, poster) {
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
titleImageRequest(context, path, width, height, poster)
}
AsyncImage(model = request, imageLoader = LocalWolfskinImageLoader.current ?: SingletonImageLoader.get(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
@ -58,11 +56,11 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo
/** Initials follow the same title-hashed hue as the locked art fallback. */
@Composable
fun PersonPortrait(person: TitlePerson, modifier: Modifier = Modifier, large: Boolean = false) {
val brush = remember(person.name) { initialsBrush(person.name) }
Box(modifier.background(brush), contentAlignment = Alignment.Center) {
if (person.portrait != null) TitleArtwork(person.portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight, Modifier.fillMaxSize())
else Text(person.name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).joinToString("") { it.take(1) }.uppercase(),
color = C.fg, style = if (large) D.personInitialSize else D.initialSize, textAlign = TextAlign.Center)
val initials = remember(person.name) { person.name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).joinToString("") { it.take(1) }.uppercase() }
val size = if (large) (L.personPortrait.value * 2).toInt() else WolfskinTokens.Image.castRequestWidth
Box(modifier.titleFallbackBackground(person.name, D.personFallbackGradient), contentAlignment = Alignment.Center) {
Text(initials, color = D.initialsColor, style = if (large) D.personInitialSize else D.initialSize, textAlign = TextAlign.Center)
if (person.portrait != null) TitleArtwork(person.portrait, size, size, Modifier.fillMaxSize())
}
}
@ -105,10 +103,43 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
}
}
private fun initialsBrush(name: String): Brush {
/** Fallback art stays behind an image, so a failed decode never leaves an empty card. */
@Composable
fun TitleCardArtwork(item: TitleItem, tile: Boolean) {
Box(Modifier.fillMaxSize().titleFallbackBackground(item.seriesName ?: item.name, D.cardFallbackGradient)) {
TitleGlyph(if (item.kind == "movie") "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph))
if (!tile) Text(item.name, color = C.fg, style = D.fallbackTitle, maxLines = 4,
modifier = Modifier.align(Alignment.BottomStart).padding(D.fallbackInset))
if (tile && item.isPosterBackdrop && item.poster != null) {
val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply {
for (index in 0 until 15) values[index] *= D.tileAmbientBrightness
}) }
TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().blur(D.tileAmbientBlur), colorFilter = filter)
val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2
TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(),
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).clip(RoundedCornerShape(D.tileSheetRadius)), poster = true)
} else {
TitleArtwork(if (tile) item.backdrop else item.poster,
if (tile) WolfskinTokens.Image.tileRequestWidth else WolfskinTokens.Image.posterRequestWidth,
if (tile) WolfskinTokens.Image.tileRequestHeight else WolfskinTokens.Image.posterRequestHeight,
Modifier.fillMaxSize(), poster = !tile)
}
}
}
private fun Modifier.titleFallbackBackground(name: String, token: String): Modifier = drawWithCache {
var hue = 0
name.forEach { hue = (hue * 31 + it.code) % 360 }
return Brush.linearGradient(listOf(Color.hsl(hue.toFloat(), .24f, .30f), Color.hsl(hue.toFloat(), .26f, .14f)))
val values = Regex("([\\d.]+)%").findAll(token).map { it.groupValues[1].toFloat() / 100 }.toList()
val angle = Math.toRadians(Regex("(\\d+)deg").find(token)!!.groupValues[1].toDouble())
val dx = kotlin.math.sin(angle).toFloat()
val dy = -kotlin.math.cos(angle).toFloat()
val half = (kotlin.math.abs(size.width * dx) + kotlin.math.abs(size.height * dy)) / 2
val vector = Offset(dx * half, dy * half)
val middle = Offset(size.width / 2, size.height / 2)
val brush = Brush.linearGradient(listOf(Color.hsl(hue.toFloat(), values[0], values[1]), Color.hsl(hue.toFloat(), values[2], values[3])),
start = middle - vector, end = middle + vector)
onDrawBehind { drawRect(brush) }
}
private fun Modifier.titleAmbientBackground(): Modifier = drawWithCache {

View file

@ -24,9 +24,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */
@Composable
fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, onClick: () -> Unit) {
val focusOwner = LocalTitleFocusOwner.current
val background = if (option.primary) C.fg else if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary
val background = if (option.primary) C.fg else if (overArtwork) {
if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary
} else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
val foreground = if (option.primary) C.onFocus else C.fg
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
@ -57,6 +59,8 @@ fun TitleGlyph(name: String, color: androidx.compose.ui.graphics.Color, modifier
"clock" -> R.drawable.title_clock
"users" -> R.drawable.title_users
"tv" -> R.drawable.title_tv
"film" -> R.drawable.title_film
"alert" -> R.drawable.title_alert
"wifi" -> R.drawable.title_wifi
"check" -> R.drawable.title_check
"sparkle" -> R.drawable.title_sparkle

View file

@ -1,5 +1,7 @@
package org.justinvisser.wolfskin.design
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -77,7 +79,9 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
}
}
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index, onFocus = { state.focus("header", index) }) { state.click("header", index) } }
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index,
Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) },
onFocus = { state.focus("header", index) }) { state.click("header", index) } }
}
if (state.options.any { it.icon }) {
val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon }

View file

@ -86,6 +86,8 @@ data class TitlePageData(
val pick: Boolean = false,
val savedAt: String? = null,
val seasonNumbers: List<Int> = List(item.seasons) { it + 1 },
val selectedSeason: Int? = null,
val episodesStatus: TitleReadStatus = TitleReadStatus.Ready,
)
/** Effects are handled by the app's authenticated facade and navigation layer. */

View file

@ -137,7 +137,7 @@ private fun WatchedConfirmation(state: TitleState) {
SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.")
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }) { state.clickSheet(1) }
TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) }
}
}
@ -158,6 +158,6 @@ private fun HiddenReceipt(state: TitleState) {
}
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }) { state.clickSheet(1) }
TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) }
}
}

View file

@ -1,5 +1,7 @@
package org.justinvisser.wolfskin.design
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -24,6 +26,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester
@ -44,22 +52,28 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
@Composable
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
LaunchedEffect(state.toast) {
if (state.toast != null) { delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null }
}
Box(modifier.fillMaxSize()) {
val focusOwner = remember(shell) { { shell.navFocused = false } }
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
ShellSurface(shell, handlesContentKeys = false, savedAt = state.data.savedAt) { TitlePage(state, shell, it) }
}
if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell)
if (!shell.pickerVisible) state.toast?.let { message ->
Row(Modifier.align(Alignment.TopEnd).padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth)
.background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph("wifi", C.warn, Modifier.size(L.buttonIcon))
Text(message, color = C.fg, style = WolfskinTokens.Type.body)
}
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
}
}
/** Playback and person reads share one calm, timed notice above the persistent nav. */
@Composable
internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDismiss: () -> Unit) {
LaunchedEffect(message) {
if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() }
}
if (message != null) {
Row(modifier.padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth)
.background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph("alert", C.warn, Modifier.size(L.buttonIcon))
Text(message, color = C.fg, style = WolfskinTokens.Type.body)
}
}
}
@ -69,6 +83,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
val density = LocalDensity.current
val list = shell.view.listState
var wheelPending by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() }
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
@ -78,31 +93,52 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
add("header")
if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) {
add("seasons")
if (state.data.episodes.isNotEmpty()) add("episodes")
if (state.data.episodesStatus == TitleReadStatus.Loading || state.data.episodes.isNotEmpty()) add("episodes")
}
if (state.data.castStatus == TitleReadStatus.Loading || state.data.cast.isNotEmpty()) add("cast")
if (state.data.related.isNotEmpty()) add("related")
}
}
LaunchedEffect(state.row, shell.navFocused, state.data.status) {
val rowOffsets = remember(displayedRows, density) {
FloatArray(displayedRows.size + 1).apply {
displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row).toPx() } }
}
}
val position by remember(list, rowOffsets) { derivedStateOf {
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
} }
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows) {
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
val y = displayedRows.take(index).fold(WolfskinTokens.Shell.zero) { total, row -> total + titleRowHeight(row) }
val height = titleRowHeight(state.row)
if (index == 0 || (y < L.windowAhead && y + height <= WolfskinTokens.Shell.stageHeight - L.safeTopPicker)) list.animateScrollToItem(0)
else list.animateScrollToItem(index, -with(density) { L.pinnedLine.roundToPx() })
val y = rowOffsets[index]
val height = with(density) { titleRowHeight(state.row).toPx() }
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
else y - with(density) { L.pinnedLine.toPx() }
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
}
val dim by remember { derivedStateOf {
val before = displayedRows.take(list.firstVisibleItemIndex).sumOf { with(density) { titleRowHeight(it).toPx().toDouble() } }
((before + list.firstVisibleItemScrollOffset) / D.dimScrollPx).toFloat().coerceAtMost(D.dimMaximum)
} }
val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
val heroVisible by remember(rowOffsets, list, density) { derivedStateOf { position < with(density) { WolfskinTokens.Shell.stageHeight.toPx() } } }
val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } }
val nextArtRow = remember(displayedRows, lastVisible) { if (lastVisible < 0) null else displayedRows.drop(lastVisible + 1).firstOrNull { it == "cast" || it == "episodes" || it == "related" } }
TitleRowPrefetch(state.data, nextArtRow)
Box(modifier.focusRequester(focus).testTag("title-page").semantics { stateDescription = "${state.data.item.id}:${state.row}:${state.column}:${state.sheet}" }
.titleWheel { if (state.data.status == TitleReadStatus.Ready) { wheelPending = true; shell.navFocused = false } }
.onPreviewKeyEvent { event ->
val key = readShellKey(event)
if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) {
val visible = list.layoutInfo.visibleItemsInfo.firstOrNull {
it.key in state.rows && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() }
}?.key as? String ?: "header"
state.focus(visible, state.columns[visible] ?: 0)
wheelPending = false
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
}
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable()) {
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().background(C.bg.copy(alpha = dim)))
if (heroVisible) {
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
} else Box(Modifier.fillMaxSize().background(C.bg))
if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state)
else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
@ -140,6 +176,7 @@ private fun TitleSeasons(state: TitleState, active: Boolean) {
val selected = state.season == number
val shape = RoundedCornerShape(WolfskinTokens.Radius.chip)
Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl)
.onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) }
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape)
.then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier)
.clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
@ -151,9 +188,17 @@ private fun TitleSeasons(state: TitleState, active: Boolean) {
@Composable
private fun TitleReadError(state: TitleState) {
Column(Modifier.offset(L.safeSide, L.navBand).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(D.overviewGap), verticalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Text("This title can’t be read", color = C.warn, style = WolfskinTokens.Type.stateTitle)
Text("Conductarr didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body)
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }) { state.click("header", index) } } }
Column(Modifier.offset(L.safeSide, D.errorTop).width(L.stateWidth)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(horizontal = D.errorSide, vertical = D.errorVertical)) {
Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph("alert", C.warn, Modifier.size(D.receiptIcon))
Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = D.errorTitle)
}
Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.errorBodyBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }, overArtwork = false) { state.click("header", index) } } }
}
}
internal val titleFrameScrollSpec = tween<Float>(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))

View file

@ -21,7 +21,20 @@ fun Modifier.titlePointerFocus(onFocus: (() -> Unit)?): Modifier {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Enter) { owner?.invoke(); latest?.invoke() }
if (event.type == PointerEventType.Enter) { owner?.invoke(); latest() }
}
}
}
}
/** Observe wheel input without consuming the frame's native scroll gesture. */
@Composable
fun Modifier.titleWheel(onScroll: () -> Unit): Modifier {
val latest by rememberUpdatedState(onScroll)
return pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
if (awaitPointerEvent().type == PointerEventType.Scroll) latest()
}
}
}

View file

@ -32,13 +32,15 @@ fun formatTitleRating(rating: TitleRating, locale: Locale = Locale.getDefault())
/** A full facts-line set never shrinks or waits for focused-card dwell. */
@Composable
fun TitleRatings(ratings: List<TitleRating>, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.button) {
fun TitleRatings(ratings: List<TitleRating>, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.button, plate: Boolean = false) {
val density = LocalDensity.current
val markHeight = with(density) { style.fontSize.toDp() }
Row(modifier, horizontalArrangement = Arrangement.spacedBy(D.ratingGap), verticalAlignment = Alignment.CenterVertically) {
val gap = if (plate) with(density) { (style.fontSize * D.ratingPlateEntryEm).toDp() } else D.ratingGap
val markGap = if (plate) with(density) { (style.fontSize * D.ratingPlateMarkEm).toDp() } else D.ratingMarkGap
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
ratings.forEach { rating ->
Row(horizontalArrangement = Arrangement.spacedBy(D.ratingMarkGap), verticalAlignment = Alignment.CenterVertically) {
if (rating.episode) Text("Ep", color = C.muted, style = style)
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
if (rating.episode) Text("Ep", color = C.muted, style = style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
val resource = when (rating.mark) {
"rotten" -> R.drawable.ic_rotten_tomatoes_rotten
"upright" -> R.drawable.ic_rt_upright
@ -47,8 +49,8 @@ fun TitleRatings(ratings: List<TitleRating>, modifier: Modifier = Modifier, styl
"tmdb" -> R.drawable.ic_rating_tmdb
else -> R.drawable.ic_rotten_tomatoes_fresh
}
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") 1.8f else 1f, markHeight),
alpha = if (rating.mark == "tomato") .55f else 1f,
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f, markHeight),
alpha = if (rating.mark == "tomato") D.mutedTomatoOpacity else 1f,
colorFilter = if (rating.mark == "tomato") ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
Text(formatTitleRating(rating), color = C.fg, style = style.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold), maxLines = 1)
}
@ -68,16 +70,27 @@ fun TitleRatingPlate(ratings: List<TitleRating>, modifier: Modifier = Modifier)
val fit = remember(ratings, width, density, Locale.getDefault()) {
var size = WolfskinTokens.Type.ratingPlate.fontSize.value
var count = ratings.size
val numbers = ratings.map { formatTitleRating(it) }
fun measured(): Float {
val text = ratings.take(count).joinToString("") { (if (it.episode) "Ep " else "") + formatTitleRating(it) }
val textWidth = measurer.measure(text, WolfskinTokens.Type.button.copy(fontSize = TextUnit(size, TextUnitType.Sp))).size.width
val marks = ratings.take(count).sumOf { if (it.source == "imdb" || it.source == "tmdb") 1.8 else 1.0 }.toFloat()
return textWidth + with(density) { TextUnit(size * marks, TextUnitType.Sp).toPx() + D.ratingMarkGap.toPx() * count + D.ratingGap.toPx() * (count - 1) }
val fontSize = TextUnit(size, TextUnitType.Sp)
val style = WolfskinTokens.Type.button.copy(fontSize = fontSize, fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold)
val markGap = with(density) { (fontSize * D.ratingPlateMarkEm).toPx() }
val entryGap = with(density) { (fontSize * D.ratingPlateEntryEm).toPx() }
var total = 0f
for (index in 0 until count) {
val rating = ratings[index]
total += measurer.measure(numbers[index], style, maxLines = 1).size.width
total += with(density) { fontSize.toPx() } * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f
total += markGap
if (rating.episode) total += measurer.measure("Ep", style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), maxLines = 1).size.width + markGap
if (index > 0) total += entryGap
}
return total
}
while (size > WolfskinTokens.Type.caption.fontSize.value && measured() > width) size = (size - .25f).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value)
while (size > WolfskinTokens.Type.caption.fontSize.value && measured() > width) size = (size - D.ratingPlateStepSp).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value)
while (count > 1 && measured() > width) count--
count to size
}
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.button.copy(fontSize = TextUnit(fit.second, TextUnitType.Sp)))
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.button.copy(fontSize = TextUnit(fit.second, TextUnitType.Sp)), plate = true)
}
}

View file

@ -1,5 +1,13 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -15,6 +23,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
@ -39,17 +50,19 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
fun TitleCast(state: TitleState, active: Boolean) {
val track = rememberLazyListState()
val focusOwner = LocalTitleFocusOwner.current
LaunchedEffect(state.column, active) { if (active) track.animateScrollToItem((state.column - 5).coerceAtLeast(0)) }
val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() }
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) }
Column(Modifier.height(D.castHeight)) {
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
if (state.data.castStatus == TitleReadStatus.Loading) {
items(7, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
}
} else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person ->
val focused = active && state.column == index
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
.onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) }
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape))
Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) {
@ -70,13 +83,20 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
val track = rememberLazyListState()
val visible = if (episodes) 4 else 6
LaunchedEffect(state.column, active) { if (active) track.animateScrollToItem((state.column - (visible - 2)).coerceAtLeast(0)) }
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) }
Column(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
TitlePoster(item, active && state.column == index, Modifier.width(width), episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
}
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
TitlePoster(item, active && state.column == index, Modifier.width(width)
.onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) },
episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
}
}
}
@ -92,24 +112,46 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
Box(Modifier.size(width, height).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
TitleArtwork(if (tile) item.backdrop ?: item.poster else item.poster, (width.value * 2).toInt(), (height.value * 2).toInt(), Modifier.fillMaxSize(), !tile)
if (item.poster == null && item.backdrop == null) Text(item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 4, modifier = Modifier.padding(D.lineGap))
TitleCardArtwork(item, tile)
if (tile) {
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg))).padding(D.lineGap)) {
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(if (item.kind == "episode") "${item.episodeLabel} · ${item.name}" else "${if (item.kind == "movie") "Movie" else "Series"} · ${item.year ?: ""}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (item.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) {
val progress = if (item.runtimeSeconds > 0) item.resumeSeconds.toFloat() / item.runtimeSeconds else 0f
if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = if (tile) D.tileProgressBottom else D.cardProgressBottom)
.fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
}
}
if (item.held && !tile) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) {
TitleGlyph("check", C.onFocus, Modifier.size(L.buttonIcon))
}
if (focused && ratingsVisible) TitleRatingPlate(item.ratings, Modifier.fillMaxWidth())
AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) {
TitleRatingPlate(item.ratings, Modifier.fillMaxWidth())
}
}
if (!tile) {
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) {
Caption(item.name, listOfNotNull(item.character?.let { "as $it" }, item.year?.toString(), if (item.kind == "movie") item.runtime else if (item.seasons > 0) "${item.seasons} seasons" else "Series", if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }).joinToString(" · "), focused,
Caption(item.name, listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), if (item.kind == "movie") item.runtime.takeIf { item.runtimeSeconds > 0 } ?: "Movie" else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }).joinToString(" · "), focused,
Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth))
}
}
}
}
/** Keep a full lookahead card, but do not recenter a row when reversing direction. */
private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float) {
val first = firstVisibleItemIndex
val next = when {
column < first -> column
column >= first + slots - 1 -> (column - slots + 2).coerceAtLeast(0)
else -> first
}
if (next != first) animateScrollBy((next - first) * pitch - firstVisibleItemScrollOffset, titleTrackScrollSpec)
}
private val titleTrackScrollSpec = tween<Float>(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))

View file

@ -17,10 +17,14 @@ class TitleState(
var row by mutableStateOf("header")
var column by mutableStateOf(0)
val columns = mutableStateMapOf<String, Int>()
private val centers = HashMap<String, MutableMap<Int, Float>>()
fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x }
fun showSeasonLoading() { if (data.episodesStatus != TitleReadStatus.Loading) data = data.copy(episodesStatus = TitleReadStatus.Loading) }
fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) }
var sheet by mutableStateOf<TitleSheet?>(null)
private set
var sheetIndex by mutableStateOf(0)
var season by mutableStateOf(initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
var versionId by mutableStateOf(initial.selectedVersion)
var audioIndex by mutableStateOf(initial.selectedAudio)
var subtitleIndex by mutableStateOf(initial.selectedSubtitle)
@ -37,7 +41,7 @@ class TitleState(
if (data.status != TitleReadStatus.Ready) return@buildList
if (data.item.held && data.item.kind == "tv" && data.item.seasons > 0) {
add("seasons")
if (data.episodes.isNotEmpty()) add("episodes")
if (data.episodesStatus == TitleReadStatus.Ready && data.episodes.isNotEmpty()) add("episodes")
}
if (data.castStatus == TitleReadStatus.Ready && data.cast.isNotEmpty()) add("cast")
if (data.related.isNotEmpty()) add("related")
@ -47,12 +51,16 @@ class TitleState(
/** Refresh keeps the selected row and file when they still exist. */
fun update(value: TitlePageData) {
val wasLoading = data.status == TitleReadStatus.Loading
val pendingWatched = watched != data.watchTarget.watched
val pendingVersion = versionId != data.selectedVersion
val pendingAudio = audioIndex != data.selectedAudio
val pendingSubtitle = subtitleIndex != data.selectedSubtitle
data = value
if (wasLoading) season = value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
watched = value.watchTarget.watched
versionId = value.selectedVersion
audioIndex = value.selectedAudio
subtitleIndex = value.selectedSubtitle
if (wasLoading || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
if (!pendingWatched) watched = value.watchTarget.watched
if (!pendingVersion) versionId = value.selectedVersion
if (!pendingAudio) audioIndex = value.selectedAudio
if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
if (row !in rows) { row = "header"; column = 0 }
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
}
@ -71,8 +79,29 @@ class TitleState(
}
/** Film and episode toggles are optimistic; the facade restores this value on failure. */
fun restoreWatched(value: Boolean) { watched = value }
/** Keep the last confirmed selections separate from immediate, optimistic UI feedback. */
fun confirmPlayback(action: TitleAction) {
data = when (action) {
is TitleAction.Version -> data.copy(selectedVersion = action.version.id, selectedAudio = null, selectedSubtitle = -1)
is TitleAction.Track -> data.copy(selectedVersion = action.version.id, selectedAudio = action.audio, selectedSubtitle = action.subtitle)
is TitleAction.Watched -> data.copy(watchTarget = data.watchTarget.copy(watched = action.watched))
else -> data
}
}
/** A failed older write cannot undo a newer selection still waiting to save. */
fun rejectPlayback(action: TitleAction) {
when (action) {
is TitleAction.Version -> if (versionId == action.version.id) {
versionId = data.selectedVersion; audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle
}
is TitleAction.Track -> if (audioIndex == action.audio && subtitleIndex == action.subtitle) {
audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle
}
is TitleAction.Watched -> if (watched == action.watched) watched = data.watchTarget.watched
else -> Unit
}
}
/** Key-up can only activate the same card that armed the first key-down. */
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
@ -86,7 +115,7 @@ class TitleState(
}
if (repeat > 0 || okHeld) return true
okHeld = true
if (sheet == null && row in listOf("cast", "related", "episodes")) {
if (sheet == null && (row == "cast" || row == "related" || row == "episodes")) {
armedCard = "$row:$column"
return true
}
@ -115,7 +144,9 @@ class TitleState(
private fun moveRow(delta: Int) {
val index = (rows.indexOf(row) + delta).coerceIn(0, rows.lastIndex)
val next = rows[index]
focus(next, (columns[next] ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
val x = centers[row]?.get(column)
val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null
focus(next, (columns[next] ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
}
private fun rowSize(id: String): Int = when (id) {

View file

@ -1,7 +1,44 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.compositionLocalOf
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
import coil3.request.ImageRequest
import coil3.request.Disposable
import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.ImageLoader
/** Owned screens share one bounded loader; standalone fixtures can use Coil's local asset loader. */
val LocalWolfskinImageLoader = compositionLocalOf<ImageLoader?> { null }
/** Visible and prefetched images use exactly the same size and decode policy. */
internal fun titleImageRequest(context: Context, path: String, width: Int, height: Int, poster: Boolean): ImageRequest =
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
/** Read the next hidden art row into the bounded loader, cancelling work when the window changes. */
@Composable
internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
val loader = LocalWolfskinImageLoader.current ?: return
val context = LocalContext.current
if (row == null || data.status != TitleReadStatus.Ready) return
DisposableEffect(loader, context, row, data.cast, data.episodes, data.related) {
val requests = ArrayList<Disposable>()
fun queue(path: String?, width: Int, height: Int, poster: Boolean = false) {
if (path != null) requests.add(loader.enqueue(titleImageRequest(context, path, width, height, poster)))
}
when (row) {
"cast" -> for (index in 0 until minOf(data.cast.size, WolfskinTokens.Card.personSlots))
queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight)
"episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) {
val item = data.episodes[index]
queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight)
}
"related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots))
queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true)
}
onDispose { requests.forEach { it.dispose() } }
}
}

View file

@ -166,26 +166,31 @@ object WolfskinTokens {
val posterGap = 12.0.dp
val posterPitch = 140.0.dp
val posterCaptionBand = 38.0.dp
const val posterSlots = 6
val tileW = 208.0.dp
val tileH = 117.0.dp
val tileGap = 12.0.dp
val tilePitch = 220.0.dp
val tileCaptionBand = 0.0.dp
const val tileSlots = 4
val wideW = 280.0.dp
val wideH = 157.5.dp
val wideGap = 12.0.dp
val widePitch = 292.0.dp
val wideCaptionBand = 0.0.dp
const val wideSlots = 3
val entryW = 128.0.dp
val entryH = 72.0.dp
val entryGap = 12.0.dp
val entryPitch = 140.0.dp
val entryCaptionBand = 0.0.dp
const val entrySlots = 6
val personW = 100.0.dp
val personH = 145.0.dp
val personGap = 14.0.dp
val personPitch = 114.0.dp
val personCaptionBand = 0.0.dp
const val personSlots = 7
}
object Focus {
const val scaleCard = 1.08f
@ -372,5 +377,44 @@ object WolfskinTokens {
const val heroAmbientBackground = "radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg))"
const val dimScrollPx = 1400.0f
const val dimMaximum = 0.7f
val errorTop = 85.0.dp
val errorSide = 20.0.dp
val errorVertical = 17.0.dp
val errorHeadingBottom = 4.0.dp
val errorBodyBottom = 12.0.dp
val errorTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight.Bold, lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em)
val fallbackGlyph = 22.0.dp
val fallbackInset = 11.0.dp
val tileSheetInset = 9.0.dp
val tileSheetLeft = 11.0.dp
val tileSheetRadius = 5.0.dp
val tileAmbientBlur = 13.0.dp
const val tileAmbientBrightness = 0.5f
const val cardFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))"
const val personFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))"
val initialsColor = ComposeColor(0xD1FFFFFF)
val fallbackGlyphColor = ComposeColor(0x73FFFFFF)
val fallbackTitle = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.099999999999998.sp, letterSpacing = 0.0.em)
val tileOverlayTop = 27.0.dp
val tileOverlaySide = 11.0.dp
val tileOverlayBottom = 9.0.dp
const val cardOverlayOpacity = 0.92f
val cardProgressInset = 9.0.dp
val cardProgressBottom = 6.0.dp
val tileProgressBottom = 5.0.dp
const val ratingPlateEntryEm = 0.64f
const val ratingPlateMarkEm = 0.29f
}
object SavedIndicator {
val height = 26.0.dp
val side = 10.0.dp
val gap = 5.0.dp
val icon = 14.0.dp
val border = 1.0.dp
const val borderOpacity = 0.5f
}
object Caption {
const val separatorOpacity = 0.4f
val factsGap = 9.0.dp
}
}

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12,3 L2,20 H22 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
<path android:pathData="M12,10 V14.5 M12,17 V17.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5.5,4 H18.5 A2.5,2.5 0,0 1,21 6.5 V17.5 A2.5,2.5 0,0 1,18.5 20 H5.5 A2.5,2.5 0,0 1,3 17.5 V6.5 A2.5,2.5 0,0 1,5.5 4 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" />
<path android:pathData="M7,4 V20 M17,4 V20 M3,9 H7 M3,15 H7 M17,9 H21 M17,15 H21" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" />
</vector>

View file

@ -78,6 +78,45 @@
"toastTop": {"dp": 11, "px": 22, "source": "index.html:270"},
"heroAmbientBackground": {"$type": "gradient", "$value": "radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg))", "source": "index.html:94"},
"dimScrollPx": {"$value": 1400, "source": "index.html:1541"},
"dimMaximum": {"$value": 0.7, "source": "index.html:1541"}
"dimMaximum": {"$value": 0.7, "source": "index.html:1541"},
"errorTop": {"dp": 85, "px": 170, "source": "index.html:1457,194"},
"errorSide": {"dp": 20, "px": 40, "source": "index.html:194"},
"errorVertical": {"dp": 17, "px": 34, "source": "index.html:194"},
"errorHeadingBottom": {"dp": 4, "px": 8, "source": "index.html:195"},
"errorBodyBottom": {"dp": 12, "px": 24, "source": "index.html:198"},
"errorTitle": {"$type": "typography", "sp": 18, "px": 36, "weight": 700, "$value": {"fontWeight": 700}, "source": "index.html:195"},
"fallbackGlyph": {"dp": 22, "px": 44, "source": "index.html:131"},
"fallbackInset": {"dp": 11, "px": 22, "source": "index.html:130-131"},
"tileSheetInset": {"dp": 9, "px": 18, "source": "index.html:128"},
"tileSheetLeft": {"dp": 11, "px": 22, "source": "index.html:128"},
"tileSheetRadius": {"dp": 5, "px": 10, "source": "index.html:128"},
"tileAmbientBlur": {"dp": 13, "px": 26, "source": "index.html:129"},
"tileAmbientBrightness": {"$value": 0.5, "source": "index.html:129"},
"cardFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))", "source": "index.html:130"},
"personFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))", "source": "index.html:288"},
"initialsColor": {"$type": "color", "$value": "#ffffffd1", "source": "index.html:288"},
"fallbackGlyphColor": {"$type": "color", "$value": "#ffffff73", "source": "index.html:131"},
"fallbackTitle": {"$type": "typography", "sp": 14, "px": 28, "weight": 700, "lineHeight": 1.15, "$value": {"fontWeight": 700}, "source": "index.html:132"},
"tileOverlayTop": {"dp": 27, "px": 54, "source": "index.html:135"},
"tileOverlaySide": {"dp": 11, "px": 22, "source": "index.html:135"},
"tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"},
"cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"},
"cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"},
"cardProgressBottom": {"dp": 6, "px": 12, "source": "index.html:138"},
"tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"},
"ratingPlateEntryEm": {"$value": 0.64, "source": "index.html:164"},
"ratingPlateMarkEm": {"$value": 0.29, "source": "index.html:165"}
},
"savedIndicator": {
"height": {"dp": 26, "px": 52, "source": "index.html:87"},
"side": {"dp": 10, "px": 20, "source": "index.html:87"},
"gap": {"dp": 5, "px": 10, "source": "index.html:87"},
"icon": {"dp": 14, "px": 28, "source": "index.html:88"},
"border": {"dp": 1, "px": 2, "source": "index.html:87"},
"borderOpacity": {"$value": 0.5, "source": "index.html:87"}
},
"caption": {
"separatorOpacity": {"$value": 0.4, "source": "spec.md:4.4; index.html:1207"},
"factsGap": {"dp": 9, "px": 18, "source": "index.html:154"}
}
}

View file

@ -246,4 +246,33 @@
--title-toastSide: 28.0px;
--title-toastTop: 22.0px;
--title-heroAmbientBackground: radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg));
--title-errorTop: 170.0px;
--title-errorSide: 40.0px;
--title-errorVertical: 34.0px;
--title-errorHeadingBottom: 8.0px;
--title-errorBodyBottom: 24.0px;
--title-errorTitle: 36.0px;
--title-fallbackGlyph: 44.0px;
--title-fallbackInset: 22.0px;
--title-tileSheetInset: 18.0px;
--title-tileSheetLeft: 22.0px;
--title-tileSheetRadius: 10.0px;
--title-tileAmbientBlur: 26.0px;
--title-cardFallbackGradient: linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%));
--title-personFallbackGradient: linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%));
--title-initialsColor: #ffffffd1;
--title-fallbackGlyphColor: #ffffff73;
--title-fallbackTitle: 28.0px;
--title-tileOverlayTop: 54.0px;
--title-tileOverlaySide: 22.0px;
--title-tileOverlayBottom: 18.0px;
--title-cardProgressInset: 18.0px;
--title-cardProgressBottom: 12.0px;
--title-tileProgressBottom: 10.0px;
--savedIndicator-height: 52.0px;
--savedIndicator-side: 20.0px;
--savedIndicator-gap: 10.0px;
--savedIndicator-icon: 28.0px;
--savedIndicator-border: 2.0px;
--caption-factsGap: 18.0px;
}

View file

@ -1,6 +1,6 @@
fa375a78bf808451823caa3c23c8aeca5b370310706ae347f978bfe5f2f0eed3 generate_tokens.py
811d6397d71d7771cad62e6745e8d85acd22a60878a4d2719792831de58d2fdb generate_tokens.py
6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
eea6348a80840947512e7a3aee599a69e91099b87ae7be2035843eb435142fb7 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
b7d9c553420775707607e906582b1d3050459cd5dc6c2c5488d5cc2b18719413 title-tokens.json
074cf8514b56b862e93a6f8794e6f5064e08dda7675e2a145ab4e2a955187905 tokens.css
1ac2b30fb03e204ab940f213714c1315f6b50224aff557b59b55230679bc6ffb src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
13526fae90cfa38063184c9d4374798387351d5e51fb8ac94f9942c898bc2163 title-tokens.json
59377d433fe2c020e72ce166780238ee6c70d2d44c86b5f167106f7b8b491394 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json