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)) 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 com.github.takahirom.roborazzi.captureRoboImage
import org.json.JSONObject import org.json.JSONObject
import org.junit.Before import org.junit.Before
import org.junit.After
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -43,14 +44,17 @@ import java.util.Locale
class TitleCaptureTest { class TitleCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: TitleFixture private lateinit var fixture: TitleFixture
private var previousLocale = Locale.getDefault()
@Before fun fullscreen() { @Before fun fullscreen() {
previousLocale = Locale.getDefault()
Locale.setDefault(Locale.US) Locale.setDefault(Locale.US)
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION") @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 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()) }) 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 = { _, _ -> }) { 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)) 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)) } 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.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } } 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) -> return requests.distinctBy { it.first }.associate { (path, width, height) ->
val bitmap = rule.activity.assets.open(path.removePrefix("file:///android_asset/")).use { BitmapFactory.decodeStream(it)!! } 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) { private fun save(number: Int) {
rule.mainClock.advanceTimeBy(1000) rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle() 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) 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.onRoot
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey 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.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.junit.Assert.* import org.junit.Assert.*
@ -184,4 +187,68 @@ class TitleKeysTest {
assertEquals(TitleSheet.Version, state.sheet) assertEquals(TitleSheet.Version, state.sheet)
assertTrue(effects.none { it is TitleAction.Version }) 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.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.junit.Assert.* import org.junit.Assert.*
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
@ -71,7 +70,7 @@ class TitleReadFacadeTest {
every { repository.currentUser } returns null every { repository.currentUser } returns null
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
val context = mockk<Context> { every { filesDir } returns directory.root } 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( 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") coEvery { library.getItem(any()) } throws java.io.IOException("offline")
val saved = facade.readSaved(firstId, preferences)!! val saved = facade.readSaved(firstId, preferences)!!
val screen = TitleState(saved, ShellPerson("justin", "Justin", "J", false)) {} 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(3, screen.audio?.index)
assertEquals(listOf(8), saved.versions.single().subtitles.map { it.index }) assertEquals(listOf(8), saved.versions.single().subtitles.map { it.index })
assertEquals(TitleConnection.SignedOut, saved.connection) assertEquals(TitleConnection.SignedOut, saved.connection)
@ -109,6 +108,19 @@ class TitleReadFacadeTest {
assertEquals("A real file", facade.readSaved(firstId, preferences)?.item?.name) 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 { @Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest {
val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9) 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) 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(firstId.toString(), data.watchTarget.id)
assertEquals(listOf(secondId.toString()), data.episodes.map { it.id }) assertEquals(listOf(secondId.toString()), data.episodes.map { it.id })
assertEquals(listOf(2), data.seasonNumbers) 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 package org.justinvisser.wolfskin.tv
import com.github.damontecres.wholphin.ui.nav.Destination 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. */ /** The rollout switch changes only intercepted screens; unrelated destinations always pass through. */
fun resolveUpstreamDestination(destination: Destination, wolfskinEnabled: Boolean, libraries: Map<String, Destination>): Destination? = fun resolveUpstreamDestination(destination: Destination, wolfskinEnabled: Boolean, libraries: Map<String, Destination>): Destination? =
when (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 is Destination.Wolfskin -> if (!wolfskinEnabled && destination.route in LIBRARY_ROUTES) libraries[destination.route] else null
else -> destination else -> destination
} }
private val LIBRARY_ROUTES = setOf("movies", "shows", "anime") 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 dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers 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.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.justinvisser.wolfskin.design.PersonState import org.justinvisser.wolfskin.design.PersonState
import org.justinvisser.wolfskin.design.PersonSurface import org.justinvisser.wolfskin.design.PersonSurface
import org.justinvisser.wolfskin.design.ShellPerson import org.justinvisser.wolfskin.design.ShellPerson
@ -47,6 +49,7 @@ fun personRoute(id: String): String = "person/${Uri.encode(id)}"
class WolfskinTitleViewModel @Inject constructor( class WolfskinTitleViewModel @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val facade: TitleReadFacade, private val facade: TitleReadFacade,
private val fixtureProvider: TitleFixtureProvider,
private val navigation: NavigationManager, private val navigation: NavigationManager,
) : ViewModel() { ) : ViewModel() {
var title by mutableStateOf<TitleState?>(null) var title by mutableStateOf<TitleState?>(null)
@ -59,24 +62,32 @@ class WolfskinTitleViewModel @Inject constructor(
private var fixture: TitleFixture? = null private var fixture: TitleFixture? = null
private var personReadPending = false private var personReadPending = false
private var personReadFinished = 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) } 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. */ /** Fixture reads are exclusively debug assets; real ids always use the authenticated Jellyfin facade. */
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences) { fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences) {
if (route == destination && ::viewer.isInitialized && viewer.key == selected.key) return if (route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
route = destination route = destination
loadJob?.cancel()
person = null
fixture = null
personReadPending = false
personReadFinished = false
viewer = selected viewer = selected
preferences = userPreferences preferences = userPreferences
val id = Uri.decode(destination.substringAfter('/')) val id = Uri.decode(destination.substringAfter('/'))
title = TitleState(TitlePageData(TitleItem(id, "", "movie"), status = TitleReadStatus.Loading), viewer, ::handle) title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = TitleReadStatus.Loading), viewer, ::handle)
viewModelScope.launch { loadJob = viewModelScope.launch {
if (BuildConfig.DEBUG && id.toUuidOrNull() == null) { 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/")) { 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) person = PersonState(actor, credits, ::handle)
} else { } 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 key = "${viewer.key}:${data.item.id}"
val watchKey = "${viewer.key}:${data.watchTarget.id}" val watchKey = "${viewer.key}:${data.watchTarget.id}"
title = TitleState(data.copy( title = TitleState(data.copy(
@ -93,50 +104,79 @@ class WolfskinTitleViewModel @Inject constructor(
private suspend fun readTitle(id: String) { private suspend fun readTitle(id: String) {
val uuid = id.toUuidOrNull() ?: return val uuid = id.toUuidOrNull() ?: return
facade.readSaved(uuid, preferences)?.let { title?.update(it) } val current = title ?: return
try { try {
val data = facade.read(uuid, title?.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences) if (current.data.status != TitleReadStatus.Ready) facade.readSaved(uuid, preferences)?.let { current.update(it) }
title?.update(data) val data = facade.read(uuid, current.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences)
current.update(data)
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is CancellationException) throw exception if (exception is CancellationException) throw exception
title?.let { current ->
if (current.data.status == TitleReadStatus.Ready) { 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." current.toast = "Still can’t reach it. The last saved view stays on screen."
} else current.update(current.data.copy(status = TitleReadStatus.Unavailable)) } else current.update(current.data.copy(status = TitleReadStatus.Unavailable))
} }
} }
}
private suspend fun readPerson(id: String) { private suspend fun readPerson(id: String) {
if (personReadPending || personReadFinished) return if (personReadPending || personReadFinished) return
val uuid = id.toUuidOrNull() ?: return val uuid = id.toUuidOrNull() ?: return
val owner = viewer.key
personReadPending = true personReadPending = true
try { 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 val current = person
if (current == null) person = PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(id) } } val credits = if (start == 0) result.credits else (current?.credits.orEmpty() + result.credits).distinctBy { it.id }
else current.credits = (current.credits + credits).distinctBy { it.id } if (current == null) person = newPerson(result.person, credits) else current.update(result.person, credits)
personReadFinished = credits.size < 200 personReadFinished = result.credits.size < 200
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is CancellationException) throw exception if (exception is CancellationException) throw exception
title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable)) if (owner == viewer.key) {
} finally { personReadPending = false } 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) { private fun handle(action: TitleAction) {
when (action) { when (action) {
TitleAction.Pop -> navigation.goBack() TitleAction.Pop -> navigation.goBack()
is TitleAction.Person -> navigation.navigateTo(Destination.Wolfskin(personRoute(action.person.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(Destination.Wolfskin(titleRoute(action.item.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.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) 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." 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.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
is TitleAction.Season -> viewModelScope.launch { is TitleAction.Season -> {
if (fixture == null) readTitle(Uri.decode(route.substringAfter('/'))) 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 { 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." 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." 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) { private fun savePlayback(action: TitleAction) {
val current = title ?: return
if (fixture != null) { if (fixture != null) {
val edits = fixtureSelections.edit() val edits = fixtureSelections.edit()
when (action) { when (action) {
@ -170,14 +211,17 @@ class WolfskinTitleViewModel @Inject constructor(
else -> Unit else -> Unit
} }
edits.apply() edits.apply()
current.confirmPlayback(action)
return return
} }
viewModelScope.launch { viewModelScope.launch {
try { facade.apply(action) } saveMutex.withLock {
try { facade.apply(action); current.confirmPlayback(action) }
catch (exception: Exception) { catch (exception: Exception) {
if (exception is CancellationException) throw exception if (exception is CancellationException) throw exception
if (action is TitleAction.Watched) title?.restoreWatched(!action.watched) current.rejectPlayback(action)
title?.toast = "Couldn’t save that change. Try again." current.toast = "Couldn’t save that change. Try again."
}
} }
} }
} }

View file

@ -1,5 +1,12 @@
package org.justinvisser.wolfskin.tv 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.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.justinvisser.wolfskin.design.ShellPerson 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) } private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy { key(it) }
/** Each viewer gets their own ownership, resume target and episode watch identity. */ /** 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 profile = profiles.first { it.getString("key") == person }
val library = listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() } val library = listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() }
val episodes = listOf("resume", "nextUp").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") } val play = selectedEpisode ?: episodes.firstOrNull { it.optString("seriesTitle") == container.getString("title") }
?: own?.takeIf { it.optDouble("progress") > 0 } ?: own?.takeIf { it.optDouble("progress") > 0 }
val target = play?.let { item(it, person) } ?: item.takeIf { it.held && it.kind == "movie" } 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 } 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) } .sortedByDescending { overlap(it, container) }.distinctBy { key(it) }.take(16).map { item(it, person) }
val castMembers = cast.optJSONArray(key(container))?.objects().orEmpty().map { member -> 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, return TitlePageData(item, play?.let { item(it, person) }, selectedEpisode?.let { item(it, person) } ?: item,
versions = target?.let { versions(it) }.orEmpty(), versions = target?.let { versions(it) }.orEmpty(),
trailerUrl = if (item.held && key(container) in TRAILERS) "fixture:trailer" else null, 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) }, 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") 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. */ /** 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) } private fun JSONArray.objects(): List<JSONObject> = List(length()) { getJSONObject(it) }

View file

@ -177,7 +177,7 @@ fun WolfskinApp(
val route = when (destination) { val route = when (destination) {
is Destination.Home -> "home" is Destination.Home -> "home"
is Destination.Wolfskin -> destination.route 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" else -> "pushed"
} }
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) } LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }

View file

@ -38,6 +38,8 @@ import java.util.Locale
import java.util.UUID import java.util.UUID
import javax.inject.Inject 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. */ /** Jellyfin SDK changes are confined to this boundary; title UI sees immutable screen data only. */
class TitleReadFacade @Inject constructor( class TitleReadFacade @Inject constructor(
private val api: ApiClient, private val api: ApiClient,
@ -59,23 +61,23 @@ class TitleReadFacade @Inject constructor(
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items")) val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items"))
val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } } 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) 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())), .copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() })
trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() })
} catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null } } 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. */ /** 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) { 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 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 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 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 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 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 { val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }
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 rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id } val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id }
val numbers = seasons.mapNotNull { it.indexNumber } val numbers = seasons.mapNotNull { it.indexNumber }
val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences) 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)) 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("seasonNumbers", org.json.JSONArray(numbers)).put("selectedSeason", selected?.indexNumber).put("watch", opened.id.toString())
.put("target", target?.id?.toString().orEmpty()).put("trailer", trailer.orEmpty()) .put("target", target?.id?.toString().orEmpty()).put("trailer", trailer.orEmpty())
val file = cacheFile(id)
file.parentFile?.mkdirs() file.parentFile?.mkdirs()
try { file.writeText(saved.toString()) } catch (_: java.io.IOException) { /* Playback remains available when the disk cannot save a copy. */ } try { file.writeText(saved.toString()) } catch (_: java.io.IOException) { /* Playback remains available when the disk cannot save a copy. */ }
result.copy(trailerUrl = trailer) result.copy(trailerUrl = trailer)
@ -110,15 +111,38 @@ class TitleReadFacade @Inject constructor(
Unit 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. */ /** 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) { suspend fun readPerson(id: UUID, start: Int): PersonRead = withContext(Dispatchers.IO) {
val person = api.userLibraryApi.getItem(id).content 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, val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200,
sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty() 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 { private suspend fun build(rows: List<BaseItemDto>, seasonNumbers: List<Int>, selectedSeason: Int?, watchId: String, targetId: String?, preferences: UserPreferences): TitlePageData {
val item = rows.first() val item = rows.first()
val target = rows.firstOrNull { it.id.toString() == targetId } 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) }) 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) }, 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( 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 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): elif isinstance(token.get("dp"), dict):
for suffix, dimension in token["dp"].items(): for suffix, dimension in token["dp"].items():
lines.append(f" val {name}{suffix[0].upper() + suffix[1:]} = {number(dimension)}.dp") 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): elif group_name == "image" and isinstance(token.get("px"), list):
for suffix, dimension in zip(("Width", "Height"), token["px"]): for suffix, dimension in zip(("Width", "Height"), token["px"]):
lines.append(f" const val {name}{suffix} = {int(dimension)}") 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.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -15,8 +20,14 @@ import androidx.tv.material3.Text
@Composable @Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) { 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 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 }) { Column(modifier.graphicsLayer { this.alpha = alpha }) {
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) 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.animation.core.spring
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background 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.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth 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.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S 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. */ /** 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) 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. */ /** There is one blob, including when focus wraps around the nav avatar. */
@Composable @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 boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) } val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) }
val index = if (state.navFocused) state.navIndex else state.currentTab 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() val avatar = boxes.last()
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
Modifier.offset(avatar.left, avatar.top) Modifier.offset(avatar.left, avatar.top)

View file

@ -1,5 +1,6 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -19,6 +20,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** Filmography retains a real credit key and requests more credits before focus reaches the tail. */ /** 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 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) var index by mutableStateOf(0)
private var armed: String? = null 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. */ /** The first grid row exits upward into the existing shell nav. */
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean { 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() val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
if (key == ShellKey.Ok) { if (key == ShellKey.Ok) {
if (repeat > 0) return true if (repeat > 0) return true
@ -72,8 +87,11 @@ class PersonState(val person: TitlePerson, initialCredits: List<TitleItem>, priv
@Composable @Composable
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } } val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
Box(modifier.fillMaxSize().background(C.bg)) {
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) { androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
ShellSurface(shell, modifier, handlesContentKeys = false) { PersonPage(state, shell, it) } 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) { private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
val density = LocalDensity.current val density = LocalDensity.current
val list = shell.view.listState val list = shell.view.listState
var wheelPending by remember { mutableStateOf(false) }
val columns = WolfskinTokens.Behaviour.gridColumns.toInt() val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
LaunchedEffect(state.index / columns, shell.navFocused) { LaunchedEffect(state.index / columns, shell.navFocused) {
if (shell.navFocused || state.index < columns) list.animateScrollToItem(0) val target = if (shell.navFocused || state.index < columns) 0f
else list.animateScrollToItem(2 + state.index / columns, -with(density) { L.pinnedLine.roundToPx() }) 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}" } 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()) { LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
item(key = "person-header", contentType = "header") { item(key = "person-header", contentType = "header") {
Box(Modifier.height(L.row_phead)) { Box(Modifier.height(L.row_phead)) {

View file

@ -58,6 +58,7 @@ fun ShellSurface(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
handlesContentKeys: Boolean = true, handlesContentKeys: Boolean = true,
settingsOpenRequests: Int? = null, settingsOpenRequests: Int? = null,
savedAt: String? = null,
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) }, content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
) { ) {
val focus = remember { FocusRequester() } val focus = remember { FocusRequester() }
@ -86,7 +87,7 @@ fun ShellSurface(
WhosWatching(state) WhosWatching(state)
} else { } else {
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) 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) { if (state.requestsVisible) {
Box(Modifier.fillMaxSize().background(C.sheetScrim)) { Box(Modifier.fillMaxSize().background(C.sheetScrim)) {
Column(Modifier.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth) 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.offset
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment 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.ShaderBrush
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.foundation.shape.RoundedCornerShape 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.unit.Dp
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -30,9 +31,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.compose.AsyncImage 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.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D 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) { else if (path != null) {
val context = LocalContext.current val context = LocalContext.current
val request = remember(context, path, width, height, poster) { 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), AsyncImage(model = request, imageLoader = LocalWolfskinImageLoader.current ?: SingletonImageLoader.get(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter) 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. */ /** Initials follow the same title-hashed hue as the locked art fallback. */
@Composable @Composable
fun PersonPortrait(person: TitlePerson, modifier: Modifier = Modifier, large: Boolean = false) { fun PersonPortrait(person: TitlePerson, modifier: Modifier = Modifier, large: Boolean = false) {
val brush = remember(person.name) { initialsBrush(person.name) } val initials = remember(person.name) { person.name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).joinToString("") { it.take(1) }.uppercase() }
Box(modifier.background(brush), contentAlignment = Alignment.Center) { val size = if (large) (L.personPortrait.value * 2).toInt() else WolfskinTokens.Image.castRequestWidth
if (person.portrait != null) TitleArtwork(person.portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight, Modifier.fillMaxSize()) Box(modifier.titleFallbackBackground(person.name, D.personFallbackGradient), contentAlignment = Alignment.Center) {
else Text(person.name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).joinToString("") { it.take(1) }.uppercase(), Text(initials, color = D.initialsColor, style = if (large) D.personInitialSize else D.initialSize, textAlign = TextAlign.Center)
color = C.fg, 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 var hue = 0
name.forEach { hue = (hue * 31 + it.code) % 360 } 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 { 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. */ /** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */
@Composable @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 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 foreground = if (option.primary) C.onFocus else C.fg
val shape = RoundedCornerShape(WolfskinTokens.Radius.button) val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX) 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 "clock" -> R.drawable.title_clock
"users" -> R.drawable.title_users "users" -> R.drawable.title_users
"tv" -> R.drawable.title_tv "tv" -> R.drawable.title_tv
"film" -> R.drawable.title_film
"alert" -> R.drawable.title_alert
"wifi" -> R.drawable.title_wifi "wifi" -> R.drawable.title_wifi
"check" -> R.drawable.title_check "check" -> R.drawable.title_check
"sparkle" -> R.drawable.title_sparkle "sparkle" -> R.drawable.title_sparkle

View file

@ -1,5 +1,7 @@
package org.justinvisser.wolfskin.design 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.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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)) { 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 }) { if (state.options.any { it.icon }) {
val option = state.options.getOrNull(state.column)?.takeIf { active && 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 pick: Boolean = false,
val savedAt: String? = null, val savedAt: String? = null,
val seasonNumbers: List<Int> = List(item.seasons) { it + 1 }, 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. */ /** 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}.") SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.")
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { 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("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)) { 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("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 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -24,6 +26,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember 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.Modifier
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -44,24 +52,30 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */ /** The shared shell remains authoritative; title sheets overlay it and trap all input. */
@Composable @Composable
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { 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()) { Box(modifier.fillMaxSize()) {
val focusOwner = remember(shell) { { shell.navFocused = false } } val focusOwner = remember(shell) { { shell.navFocused = false } }
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) { 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.sheet != null) TitleOptionsSheet(state, shell)
if (!shell.pickerVisible) state.toast?.let { message -> if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
Row(Modifier.align(Alignment.TopEnd).padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth) }
}
/** 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), .background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph("wifi", C.warn, Modifier.size(L.buttonIcon)) TitleGlyph("alert", C.warn, Modifier.size(L.buttonIcon))
Text(message, color = C.fg, style = WolfskinTokens.Type.body) Text(message, color = C.fg, style = WolfskinTokens.Type.body)
} }
} }
}
} }
/** One keyed vertical frame renders the exact header, season, cast and related order. */ /** One keyed vertical frame renders the exact header, season, cast and related order. */
@ -69,6 +83,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
val density = LocalDensity.current val density = LocalDensity.current
val list = shell.view.listState val list = shell.view.listState
var wheelPending by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() } val focus = remember { FocusRequester() }
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) { LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() 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") add("header")
if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) { if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) {
add("seasons") 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.castStatus == TitleReadStatus.Loading || state.data.cast.isNotEmpty()) add("cast")
if (state.data.related.isNotEmpty()) add("related") 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) if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0) 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 y = rowOffsets[index]
val height = titleRowHeight(state.row) val height = with(density) { titleRowHeight(state.row).toPx() }
if (index == 0 || (y < L.windowAhead && y + height <= WolfskinTokens.Shell.stageHeight - L.safeTopPicker)) list.animateScrollToItem(0) val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
else list.animateScrollToItem(index, -with(density) { L.pinnedLine.roundToPx() }) else y - with(density) { L.pinnedLine.toPx() }
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
} }
val dim by remember { derivedStateOf { val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
val before = displayedRows.take(list.firstVisibleItemIndex).sumOf { with(density) { titleRowHeight(it).toPx().toDouble() } } val heroVisible by remember(rowOffsets, list, density) { derivedStateOf { position < with(density) { WolfskinTokens.Shell.stageHeight.toPx() } } }
((before + list.firstVisibleItemScrollOffset) / D.dimScrollPx).toFloat().coerceAtMost(D.dimMaximum) 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}" } 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 -> .onPreviewKeyEvent { event ->
val key = readShellKey(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) key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable()) { }.focusable()) {
if (heroVisible) {
TitleBackdrop(state.data.item, Modifier.fillMaxSize()) TitleBackdrop(state.data.item, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().background(C.bg.copy(alpha = dim))) Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
} else Box(Modifier.fillMaxSize().background(C.bg))
if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state) if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state)
else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) { 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") { 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 selected = state.season == number
val shape = RoundedCornerShape(WolfskinTokens.Radius.chip) val shape = RoundedCornerShape(WolfskinTokens.Radius.chip)
Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl) 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) .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) .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) { .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 @Composable
private fun TitleReadError(state: TitleState) { 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)) { Column(Modifier.offset(L.safeSide, D.errorTop).width(L.stateWidth)
Text("This title can’t be read", color = C.warn, style = WolfskinTokens.Type.stateTitle) .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
Text("Conductarr didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body) .border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
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) } } } .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 { awaitPointerEventScope {
while (true) { while (true) {
val event = awaitPointerEvent() 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. */ /** A full facts-line set never shrinks or waits for focused-card dwell. */
@Composable @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 density = LocalDensity.current
val markHeight = with(density) { style.fontSize.toDp() } 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 -> ratings.forEach { rating ->
Row(horizontalArrangement = Arrangement.spacedBy(D.ratingMarkGap), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
if (rating.episode) Text("Ep", color = C.muted, style = style) if (rating.episode) Text("Ep", color = C.muted, style = style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
val resource = when (rating.mark) { val resource = when (rating.mark) {
"rotten" -> R.drawable.ic_rotten_tomatoes_rotten "rotten" -> R.drawable.ic_rotten_tomatoes_rotten
"upright" -> R.drawable.ic_rt_upright "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 "tmdb" -> R.drawable.ic_rating_tmdb
else -> R.drawable.ic_rotten_tomatoes_fresh 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), 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") .55f else 1f, alpha = if (rating.mark == "tomato") D.mutedTomatoOpacity else 1f,
colorFilter = if (rating.mark == "tomato") ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null) 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) 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()) { val fit = remember(ratings, width, density, Locale.getDefault()) {
var size = WolfskinTokens.Type.ratingPlate.fontSize.value var size = WolfskinTokens.Type.ratingPlate.fontSize.value
var count = ratings.size var count = ratings.size
val numbers = ratings.map { formatTitleRating(it) }
fun measured(): Float { fun measured(): Float {
val text = ratings.take(count).joinToString("") { (if (it.episode) "Ep " else "") + formatTitleRating(it) } val fontSize = TextUnit(size, TextUnitType.Sp)
val textWidth = measurer.measure(text, WolfskinTokens.Type.button.copy(fontSize = TextUnit(size, TextUnitType.Sp))).size.width val style = WolfskinTokens.Type.button.copy(fontSize = fontSize, fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold)
val marks = ratings.take(count).sumOf { if (it.source == "imdb" || it.source == "tmdb") 1.8 else 1.0 }.toFloat() val markGap = with(density) { (fontSize * D.ratingPlateMarkEm).toPx() }
return textWidth + with(density) { TextUnit(size * marks, TextUnitType.Sp).toPx() + D.ratingMarkGap.toPx() * count + D.ratingGap.toPx() * (count - 1) } 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
} }
while (size > WolfskinTokens.Type.caption.fontSize.value && measured() > width) size = (size - .25f).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value) return total
}
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-- while (count > 1 && measured() > width) count--
count to size 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 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement 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.width
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow 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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape 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) { fun TitleCast(state: TitleState, active: Boolean) {
val track = rememberLazyListState() val track = rememberLazyListState()
val focusOwner = LocalTitleFocusOwner.current 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)) { Column(Modifier.height(D.castHeight)) {
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) 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)) { LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
if (state.data.castStatus == TitleReadStatus.Loading) { 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)) Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
} }
} else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person -> } else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person ->
val focused = active && state.column == index val focused = active && state.column == index
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f) 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}")) { .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)) 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) { 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 width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
val track = rememberLazyListState() val track = rememberLazyListState()
val visible = if (episodes) 4 else 6 val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
LaunchedEffect(state.column, active) { if (active) track.animateScrollToItem((state.column - (visible - 2)).coerceAtLeast(0)) } 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)) { 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)) 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)) { 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 -> if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
TitlePoster(item, active && state.column == index, Modifier.width(width), episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) } 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) 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) .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() }) { .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) TitleCardArtwork(item, 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))
if (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(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) 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)) 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) { if (!tile) {
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) { 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)) 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 row by mutableStateOf("header")
var column by mutableStateOf(0) var column by mutableStateOf(0)
val columns = mutableStateMapOf<String, Int>() 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) var sheet by mutableStateOf<TitleSheet?>(null)
private set private set
var sheetIndex by mutableStateOf(0) 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 versionId by mutableStateOf(initial.selectedVersion)
var audioIndex by mutableStateOf(initial.selectedAudio) var audioIndex by mutableStateOf(initial.selectedAudio)
var subtitleIndex by mutableStateOf(initial.selectedSubtitle) var subtitleIndex by mutableStateOf(initial.selectedSubtitle)
@ -37,7 +41,7 @@ class TitleState(
if (data.status != TitleReadStatus.Ready) return@buildList if (data.status != TitleReadStatus.Ready) return@buildList
if (data.item.held && data.item.kind == "tv" && data.item.seasons > 0) { if (data.item.held && data.item.kind == "tv" && data.item.seasons > 0) {
add("seasons") 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.castStatus == TitleReadStatus.Ready && data.cast.isNotEmpty()) add("cast")
if (data.related.isNotEmpty()) add("related") if (data.related.isNotEmpty()) add("related")
@ -47,12 +51,16 @@ class TitleState(
/** Refresh keeps the selected row and file when they still exist. */ /** Refresh keeps the selected row and file when they still exist. */
fun update(value: TitlePageData) { fun update(value: TitlePageData) {
val wasLoading = data.status == TitleReadStatus.Loading 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 data = value
if (wasLoading) season = value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1 if (wasLoading || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
watched = value.watchTarget.watched if (!pendingWatched) watched = value.watchTarget.watched
versionId = value.selectedVersion if (!pendingVersion) versionId = value.selectedVersion
audioIndex = value.selectedAudio if (!pendingAudio) audioIndex = value.selectedAudio
subtitleIndex = value.selectedSubtitle if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
if (row !in rows) { row = "header"; column = 0 } if (row !in rows) { row = "header"; column = 0 }
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(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. */ /** Keep the last confirmed selections separate from immediate, optimistic UI feedback. */
fun restoreWatched(value: Boolean) { watched = value } 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. */ /** 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 { fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
@ -86,7 +115,7 @@ class TitleState(
} }
if (repeat > 0 || okHeld) return true if (repeat > 0 || okHeld) return true
okHeld = true okHeld = true
if (sheet == null && row in listOf("cast", "related", "episodes")) { if (sheet == null && (row == "cast" || row == "related" || row == "episodes")) {
armedCard = "$row:$column" armedCard = "$row:$column"
return true return true
} }
@ -115,7 +144,9 @@ class TitleState(
private fun moveRow(delta: Int) { private fun moveRow(delta: Int) {
val index = (rows.indexOf(row) + delta).coerceIn(0, rows.lastIndex) val index = (rows.indexOf(row) + delta).coerceIn(0, rows.lastIndex)
val next = rows[index] 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) { private fun rowSize(id: String): Int = when (id) {

View file

@ -1,7 +1,44 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.runtime.compositionLocalOf 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 import coil3.ImageLoader
/** Owned screens share one bounded loader; standalone fixtures can use Coil's local asset loader. */ /** Owned screens share one bounded loader; standalone fixtures can use Coil's local asset loader. */
val LocalWolfskinImageLoader = compositionLocalOf<ImageLoader?> { null } 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 posterGap = 12.0.dp
val posterPitch = 140.0.dp val posterPitch = 140.0.dp
val posterCaptionBand = 38.0.dp val posterCaptionBand = 38.0.dp
const val posterSlots = 6
val tileW = 208.0.dp val tileW = 208.0.dp
val tileH = 117.0.dp val tileH = 117.0.dp
val tileGap = 12.0.dp val tileGap = 12.0.dp
val tilePitch = 220.0.dp val tilePitch = 220.0.dp
val tileCaptionBand = 0.0.dp val tileCaptionBand = 0.0.dp
const val tileSlots = 4
val wideW = 280.0.dp val wideW = 280.0.dp
val wideH = 157.5.dp val wideH = 157.5.dp
val wideGap = 12.0.dp val wideGap = 12.0.dp
val widePitch = 292.0.dp val widePitch = 292.0.dp
val wideCaptionBand = 0.0.dp val wideCaptionBand = 0.0.dp
const val wideSlots = 3
val entryW = 128.0.dp val entryW = 128.0.dp
val entryH = 72.0.dp val entryH = 72.0.dp
val entryGap = 12.0.dp val entryGap = 12.0.dp
val entryPitch = 140.0.dp val entryPitch = 140.0.dp
val entryCaptionBand = 0.0.dp val entryCaptionBand = 0.0.dp
const val entrySlots = 6
val personW = 100.0.dp val personW = 100.0.dp
val personH = 145.0.dp val personH = 145.0.dp
val personGap = 14.0.dp val personGap = 14.0.dp
val personPitch = 114.0.dp val personPitch = 114.0.dp
val personCaptionBand = 0.0.dp val personCaptionBand = 0.0.dp
const val personSlots = 7
} }
object Focus { object Focus {
const val scaleCard = 1.08f 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 heroAmbientBackground = "radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg))"
const val dimScrollPx = 1400.0f const val dimScrollPx = 1400.0f
const val dimMaximum = 0.7f 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"}, "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"}, "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"}, "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-toastSide: 28.0px;
--title-toastTop: 22.0px; --title-toastTop: 22.0px;
--title-heroAmbientBackground: radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg)); --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 6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
eea6348a80840947512e7a3aee599a69e91099b87ae7be2035843eb435142fb7 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 1ac2b30fb03e204ab940f213714c1315f6b50224aff557b59b55230679bc6ffb src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
b7d9c553420775707607e906582b1d3050459cd5dc6c2c5488d5cc2b18719413 title-tokens.json 13526fae90cfa38063184c9d4374798387351d5e51fb8ac94f9942c898bc2163 title-tokens.json
074cf8514b56b862e93a6f8794e6f5064e08dda7675e2a145ab4e2a955187905 tokens.css 59377d433fe2c020e72ce166780238ee6c70d2d44c86b5f167106f7b8b491394 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json