From 6d7df0fb19fba2bca8d7ee5f64778f94a55cd1cc Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 17:13:53 +0100 Subject: [PATCH] Preserve title data and focus across updates --- .../wolfskin/tv/ShellRoutingTest.kt | 13 +++ .../wolfskin/tv/TitleCaptureTest.kt | 10 +- .../justinvisser/wolfskin/tv/TitleKeysTest.kt | 67 +++++++++++ .../wolfskin/tv/TitleReadFacadeTest.kt | 19 +++- .../justinvisser/wolfskin/tv/ShellRouting.kt | 5 +- .../wolfskin/tv/TitleDestination.kt | 104 +++++++++++++----- .../justinvisser/wolfskin/tv/TitleFixture.kt | 26 ++++- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 2 +- .../wolfskin/tv/read/TitleReadFacade.kt | 50 +++++++-- wolfskin-design/generate_tokens.py | 2 + .../justinvisser/wolfskin/design/Caption.kt | 13 ++- .../justinvisser/wolfskin/design/NavBar.kt | 17 ++- .../wolfskin/design/PersonPage.kt | 51 +++++++-- .../wolfskin/design/ShellSurface.kt | 3 +- .../wolfskin/design/TitleArtwork.kt | 55 +++++++-- .../wolfskin/design/TitleControl.kt | 8 +- .../wolfskin/design/TitleHeader.kt | 6 +- .../wolfskin/design/TitleModel.kt | 2 + .../wolfskin/design/TitleOptionsSheet.kt | 4 +- .../justinvisser/wolfskin/design/TitlePage.kt | 99 ++++++++++++----- .../wolfskin/design/TitlePointer.kt | 15 ++- .../wolfskin/design/TitleRating.kt | 37 +++++-- .../wolfskin/design/TitleShelf.kt | 66 +++++++++-- .../wolfskin/design/TitleState.kt | 53 +++++++-- .../wolfskin/design/WolfskinImages.kt | 37 +++++++ .../wolfskin/design/WolfskinTokens.kt | 44 ++++++++ .../src/main/res/drawable/title_alert.xml | 4 + .../src/main/res/drawable/title_film.xml | 4 + wolfskin-design/title-tokens.json | 41 ++++++- wolfskin-design/tokens.css | 29 +++++ wolfskin-design/tokens.sha256 | 8 +- 31 files changed, 747 insertions(+), 147 deletions(-) create mode 100644 wolfskin-design/src/main/res/drawable/title_alert.xml create mode 100644 wolfskin-design/src/main/res/drawable/title_film.xml diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt index d1944584..a5b181f7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt @@ -54,4 +54,17 @@ class ShellRoutingTest { assertEquals(Destination.Search("name"), resolveUpstreamDestination(Destination.Search("name"), enabled, libraries)) } } + + @Test fun titleRolloutOwnsOnlyFilmSeriesEpisodeAndPerson() { + val id = UUID.fromString("00000000-0000-0000-0000-000000000001") + for (kind in listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE, BaseItemKind.PERSON)) { + val destination = Destination.MediaItem(id, kind) + assertNull(resolveUpstreamDestination(destination, true, libraries)) + assertEquals(destination, resolveUpstreamDestination(destination, false, libraries)) + } + for (kind in listOf(BaseItemKind.COLLECTION_FOLDER, BaseItemKind.AUDIO, BaseItemKind.MUSIC_ALBUM, BaseItemKind.BOX_SET)) { + val destination = Destination.MediaItem(id, kind) + assertEquals(destination, resolveUpstreamDestination(destination, true, libraries)) + } + } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt index 6aaa1ed1..7d98a85a 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.test.onRoot import com.github.takahirom.roborazzi.captureRoboImage import org.json.JSONObject import org.junit.Before +import org.junit.After import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -43,14 +44,17 @@ import java.util.Locale class TitleCaptureTest { @get:Rule val rule = createAndroidComposeRule() private lateinit var fixture: TitleFixture + private var previousLocale = Locale.getDefault() @Before fun fullscreen() { + previousLocale = Locale.getDefault() Locale.setDefault(Locale.US) rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) @Suppress("DEPRECATION") rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) } + @After fun restoreLocale() { Locale.setDefault(previousLocale) } private fun capture(number: Int, title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }, configure: (TitleState, ShellState) -> Unit = { _, _ -> }) { val data = transform(fixture.title(title, person)) @@ -75,7 +79,9 @@ class TitleCaptureTest { if (data.item.backdrop == null || data.item.isPosterBackdrop) data.item.poster?.let { add(Triple(it, 420, 630)) } data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } } data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } } - data.episodes.forEach { item -> (item.backdrop ?: item.poster)?.let { add(Triple(it, 416, 234)) } } + data.episodes.forEach { item -> + (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 416, 234)) } + } } return requests.distinctBy { it.first }.associate { (path, width, height) -> val bitmap = rule.activity.assets.open(path.removePrefix("file:///android_asset/")).use { BitmapFactory.decodeStream(it)!! } @@ -89,7 +95,7 @@ class TitleCaptureTest { private fun save(number: Int) { rule.mainClock.advanceTimeBy(1000) rule.waitForIdle() - val directory = File(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() } + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } rule.onRoot().captureRoboImage(File(directory, "st-${number.toString().padStart(3, '0')}.png").absolutePath) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 01c32a75..2b17330f 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -7,6 +7,9 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performMouseInput +import org.justinvisser.wolfskin.design.WolfskinTokens import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.* @@ -184,4 +187,68 @@ class TitleKeysTest { assertEquals(TitleSheet.Version, state.sheet) assertTrue(effects.none { it is TitleAction.Version }) } + + @Test fun episodeWatchedToggleKeepsItsIdentityAndDoesNotAskAboutTheSeries() { + val episode = requireNotNull(fixture.title("Andor", "justin").playTarget) + render(episode.id) + rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "watched" }) } + press(Key.DirectionCenter) + assertNull(state.sheet) + val change = effects.single() as TitleAction.Watched + assertEquals(episode.id, change.item.id) + assertEquals(!episode.watched, change.watched) + } + + @Test fun aPendingSeasonSkipsItsEpisodeSkeletons() { + render("Andor") + rule.runOnIdle { state.showSeasonLoading() } + press(Key.DirectionDown); press(Key.DirectionDown) + assertEquals("cast", state.row) + rule.runOnIdle { state.restoreSeason() } + press(Key.DirectionUp) + assertEquals("episodes", state.row) + } + + @Test fun aFailedOlderVersionWriteCannotUndoTheNewerChoice() { + render() + press(Key.DirectionRight); press(Key.DirectionCenter); press(Key.DirectionDown); press(Key.DirectionCenter) + val first = effects.last() as TitleAction.Version + press(Key.DirectionCenter); press(Key.DirectionUp); press(Key.DirectionCenter) + val second = effects.last() as TitleAction.Version + rule.runOnIdle { state.rejectPlayback(first); state.confirmPlayback(second) } + assertEquals("v1", state.versionId) + press(Key.DirectionCenter); press(Key.DirectionDown); press(Key.DirectionCenter) + val failed = effects.last() as TitleAction.Version + rule.runOnIdle { state.rejectPlayback(failed) } + assertEquals("v1", state.versionId) + } + + @Test fun aNewRowUsesTheNearestHorizontalCardBeforeItsOwnRememberedCard() { + render() + repeat(4) { press(Key.DirectionRight) } + val x = rule.onNodeWithTag("title-option-trailer").fetchSemanticsNode().boundsInRoot.center.x + val cast = state.data.cast.take(WolfskinTokens.Card.personSlots) + val nearest = cast.indices.minBy { index -> + kotlin.math.abs(rule.onNodeWithTag("title-cast-${cast[index].id}").fetchSemanticsNode().boundsInRoot.center.x - x) + } + press(Key.DirectionDown) + assertEquals(nearest, state.column) + press(Key.DirectionRight) + val remembered = state.column + press(Key.DirectionUp); press(Key.DirectionDown) + assertEquals(remembered, state.column) + } + + @Test fun wheelThenRemoteLandsOnTheFirstVisibleFocusableRow() { + render() + rule.onNodeWithTag("title-page").performMouseInput { scroll(800f) } + val expected = rule.runOnIdle { + shell.view.listState.layoutInfo.visibleItemsInfo.first { + it.key in state.rows && it.offset + it.size > WolfskinTokens.Layout.pinnedLine.value * 2 + }.key as String + } + assertNotEquals("header", expected) + press(Key.DirectionDown) + assertEquals(expected, state.row) + } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt index 23011ec7..083a253e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -29,7 +29,6 @@ import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.junit.Assert.* import org.junit.Before import org.junit.Rule @@ -71,7 +70,7 @@ class TitleReadFacadeTest { every { repository.currentUser } returns null coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null val context = mockk { every { filesDir } returns directory.root } - facade = TitleReadFacade(api, mockk(), playback, repository, mockk(), context) + facade = TitleReadFacade(api, mockk(relaxed = true), playback, repository, mockk(), context) } private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE) = BaseItemDto( @@ -89,7 +88,7 @@ class TitleReadFacadeTest { coEvery { library.getItem(any()) } throws java.io.IOException("offline") val saved = facade.readSaved(firstId, preferences)!! val screen = TitleState(saved, ShellPerson("justin", "Justin", "J", false)) {} - assertEquals("mkv".uppercase(), saved.versions.single().container) + assertEquals("MKV", saved.versions.single().container) assertEquals(3, screen.audio?.index) assertEquals(listOf(8), saved.versions.single().subtitles.map { it.index }) assertEquals(TitleConnection.SignedOut, saved.connection) @@ -109,6 +108,19 @@ class TitleReadFacadeTest { assertEquals("A real file", facade.readSaved(firstId, preferences)?.item?.name) } + @Test fun personBiographyAndContinuousCreditsRemainReadableFromDiskWithoutAService() = runTest { + val actor = BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor", overview = "Actual biography") + coEvery { library.getItem(firstId) } returns successResponse(actor) + coEvery { items.getItems(any()) } returns successQueryResult(listOf(file(secondId))) + facade.readPerson(firstId, 0) + coEvery { library.getItem(any()) } throws java.io.IOException("offline") + coEvery { items.getItems(any()) } throws java.io.IOException("offline") + val saved = facade.readPersonSaved(firstId)!! + assertEquals("Actual biography", saved.person.biography) + assertEquals(secondId.toString(), saved.credits.single().id) + assertEquals("A real file", saved.credits.single().name) + } + @Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest { val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9) val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1) @@ -124,5 +136,6 @@ class TitleReadFacadeTest { assertEquals(firstId.toString(), data.watchTarget.id) assertEquals(listOf(secondId.toString()), data.episodes.map { it.id }) assertEquals(listOf(2), data.seasonNumbers) + assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season) } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt index e69d1496..dffa611b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt @@ -1,13 +1,16 @@ package org.justinvisser.wolfskin.tv import com.github.damontecres.wholphin.ui.nav.Destination +import org.jellyfin.sdk.model.api.BaseItemKind /** The rollout switch changes only intercepted screens; unrelated destinations always pass through. */ fun resolveUpstreamDestination(destination: Destination, wolfskinEnabled: Boolean, libraries: Map): Destination? = when (destination) { - is Destination.Home, is Destination.MediaItem -> if (wolfskinEnabled) null else destination + is Destination.Home -> if (wolfskinEnabled) null else destination + is Destination.MediaItem -> if (wolfskinEnabled && destination.type in TITLE_KINDS) null else destination is Destination.Wolfskin -> if (!wolfskinEnabled && destination.route in LIBRARY_ROUTES) libraries[destination.route] else null else -> destination } private val LIBRARY_ROUTES = setOf("movies", "shows", "anime") +private val TITLE_KINDS = setOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE, BaseItemKind.PERSON) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index 22759dd8..f361376f 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -20,9 +20,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.json.JSONObject import org.justinvisser.wolfskin.design.PersonState import org.justinvisser.wolfskin.design.PersonSurface import org.justinvisser.wolfskin.design.ShellPerson @@ -47,6 +49,7 @@ fun personRoute(id: String): String = "person/${Uri.encode(id)}" class WolfskinTitleViewModel @Inject constructor( @param:ApplicationContext private val context: Context, private val facade: TitleReadFacade, + private val fixtureProvider: TitleFixtureProvider, private val navigation: NavigationManager, ) : ViewModel() { var title by mutableStateOf(null) @@ -59,24 +62,32 @@ class WolfskinTitleViewModel @Inject constructor( private var fixture: TitleFixture? = null private var personReadPending = false private var personReadFinished = false + private var loadJob: Job? = null + private val saveMutex = Mutex() private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) } /** Fixture reads are exclusively debug assets; real ids always use the authenticated Jellyfin facade. */ fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences) { if (route == destination && ::viewer.isInitialized && viewer.key == selected.key) return route = destination + loadJob?.cancel() + person = null + fixture = null + personReadPending = false + personReadFinished = false viewer = selected preferences = userPreferences val id = Uri.decode(destination.substringAfter('/')) - title = TitleState(TitlePageData(TitleItem(id, "", "movie"), status = TitleReadStatus.Loading), viewer, ::handle) - viewModelScope.launch { + title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = TitleReadStatus.Loading), viewer, ::handle) + loadJob = viewModelScope.launch { if (BuildConfig.DEBUG && id.toUuidOrNull() == null) { - fixture = withContext(Dispatchers.IO) { TitleFixture(context.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) } + val source = fixtureProvider.read() + fixture = source if (destination.startsWith("person/")) { - val (actor, credits) = fixture!!.person(id, viewer.key) + val (actor, credits) = withContext(Dispatchers.IO) { source.person(id, selected.key) } person = PersonState(actor, credits, ::handle) } else { - val data = fixture!!.title(id, viewer.key) + val data = withContext(Dispatchers.IO) { source.title(id, selected.key) } val key = "${viewer.key}:${data.item.id}" val watchKey = "${viewer.key}:${data.watchTarget.id}" title = TitleState(data.copy( @@ -93,50 +104,79 @@ class WolfskinTitleViewModel @Inject constructor( private suspend fun readTitle(id: String) { val uuid = id.toUuidOrNull() ?: return - facade.readSaved(uuid, preferences)?.let { title?.update(it) } + val current = title ?: return try { - val data = facade.read(uuid, title?.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences) - title?.update(data) + if (current.data.status != TitleReadStatus.Ready) facade.readSaved(uuid, preferences)?.let { current.update(it) } + val data = facade.read(uuid, current.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences) + current.update(data) } catch (exception: Exception) { if (exception is CancellationException) throw exception - title?.let { current -> - if (current.data.status == TitleReadStatus.Ready) { - current.toast = "Still can’t reach it. The last saved view stays on screen." - } else current.update(current.data.copy(status = TitleReadStatus.Unavailable)) - } + if (current.data.status == TitleReadStatus.Ready) { + if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason() + current.toast = "Still can’t reach it. The last saved view stays on screen." + } else current.update(current.data.copy(status = TitleReadStatus.Unavailable)) } } private suspend fun readPerson(id: String) { if (personReadPending || personReadFinished) return val uuid = id.toUuidOrNull() ?: return + val owner = viewer.key personReadPending = true try { - val (actor, credits) = facade.readPerson(uuid, person?.credits?.size ?: 0) + if (person == null) facade.readPersonSaved(uuid)?.let { saved -> + if (owner == viewer.key) person = newPerson(saved.person, saved.credits).apply { savedAt = saved.savedAt } + } + val start = if (person?.savedAt != null) 0 else person?.credits?.size ?: 0 + val result = facade.readPerson(uuid, start) + if (owner != viewer.key) return val current = person - if (current == null) person = PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(id) } } - else current.credits = (current.credits + credits).distinctBy { it.id } - personReadFinished = credits.size < 200 + val credits = if (start == 0) result.credits else (current?.credits.orEmpty() + result.credits).distinctBy { it.id } + if (current == null) person = newPerson(result.person, credits) else current.update(result.person, credits) + personReadFinished = result.credits.size < 200 } catch (exception: Exception) { if (exception is CancellationException) throw exception - title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable)) - } finally { personReadPending = false } + if (owner == viewer.key) { + person?.toast = "Still can’t reach it. The last saved view stays on screen." + if (person == null) title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable)) + } + } finally { if (owner == viewer.key) personReadPending = false } } + private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List) = + PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } } + private fun handle(action: TitleAction) { when (action) { TitleAction.Pop -> navigation.goBack() - is TitleAction.Person -> navigation.navigateTo(Destination.Wolfskin(personRoute(action.person.id))) - is TitleAction.OpenTitle -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.item.id))) + is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id))) + is TitleAction.OpenTitle -> navigation.navigateTo(action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { + "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE + "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE + else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES + }) } ?: Destination.Wolfskin(titleRoute(action.item.id))) is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000) is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0) else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer." is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action) - is TitleAction.Season -> viewModelScope.launch { - if (fixture == null) readTitle(Uri.decode(route.substringAfter('/'))) + is TitleAction.Season -> { + loadJob?.cancel() + loadJob = viewModelScope.launch { + val current = title ?: return@launch + current.showSeasonLoading() + val source = fixture + if (source == null) readTitle(Uri.decode(route.substringAfter('/'))) + else { + val data = withContext(Dispatchers.IO) { source.title(Uri.decode(route.substringAfter('/')), viewer.key, action.number) } + current.update(data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex, + selectedSubtitle = current.subtitleIndex, watchTarget = current.data.watchTarget.copy(watched = current.watched), hidden = current.data.hidden)) + } + } } TitleAction.Retry -> viewModelScope.launch { - if (fixture == null) readTitle(Uri.decode(route.substringAfter('/'))) + if (fixture == null) { + if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/'))) else readTitle(Uri.decode(route.substringAfter('/'))) + } else title?.toast = "Still can’t reach it. The last saved view stays on screen." } is TitleAction.Request, is TitleAction.Requests -> title?.toast = "Requests need this TV to be signed in to Conductarr." @@ -158,6 +198,7 @@ class WolfskinTitleViewModel @Inject constructor( } private fun savePlayback(action: TitleAction) { + val current = title ?: return if (fixture != null) { val edits = fixtureSelections.edit() when (action) { @@ -170,14 +211,17 @@ class WolfskinTitleViewModel @Inject constructor( else -> Unit } edits.apply() + current.confirmPlayback(action) return } viewModelScope.launch { - try { facade.apply(action) } - catch (exception: Exception) { - if (exception is CancellationException) throw exception - if (action is TitleAction.Watched) title?.restoreWatched(!action.watched) - title?.toast = "Couldn’t save that change. Try again." + saveMutex.withLock { + try { facade.apply(action); current.confirmPlayback(action) } + catch (exception: Exception) { + if (exception is CancellationException) throw exception + current.rejectPlayback(action) + current.toast = "Couldn’t save that change. Try again." + } } } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt index 934959bd..c7fe1b84 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt @@ -1,5 +1,12 @@ package org.justinvisser.wolfskin.tv +import android.content.Context +import com.github.damontecres.wholphin.BuildConfig +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton import org.json.JSONArray import org.json.JSONObject import org.justinvisser.wolfskin.design.ShellPerson @@ -22,7 +29,7 @@ class TitleFixture(private val fixture: JSONObject) { private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy { key(it) } /** Each viewer gets their own ownership, resume target and episode watch identity. */ - fun title(id: String, person: String): TitlePageData { + fun title(id: String, person: String, season: Int? = null): TitlePageData { val profile = profiles.first { it.getString("key") == person } val library = listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() } val episodes = listOf("resume", "nextUp").flatMap { profile.getJSONArray(it).objects() } @@ -34,6 +41,7 @@ class TitleFixture(private val fixture: JSONObject) { val play = selectedEpisode ?: episodes.firstOrNull { it.optString("seriesTitle") == container.getString("title") } ?: own?.takeIf { it.optDouble("progress") > 0 } val target = play?.let { item(it, person) } ?: item.takeIf { it.held && it.kind == "movie" } + val selectedSeason = season ?: play?.optInt("seasonNumber")?.takeIf { it > 0 } ?: 1 val related = (pool + library).filter { key(it) != key(container) && overlap(it, container) > 0 } .sortedByDescending { overlap(it, container) }.distinctBy { key(it) }.take(16).map { item(it, person) } val castMembers = cast.optJSONArray(key(container))?.objects().orEmpty().map { member -> @@ -42,8 +50,8 @@ class TitleFixture(private val fixture: JSONObject) { return TitlePageData(item, play?.let { item(it, person) }, selectedEpisode?.let { item(it, person) } ?: item, versions = target?.let { versions(it) }.orEmpty(), trailerUrl = if (item.held && key(container) in TRAILERS) "fixture:trailer" else null, - cast = castMembers, related = related, episodes = episodes.filter { it.optString("seriesTitle") == container.getString("title") }.map { item(it, person) }, - connection = TitleConnection.SignedIn, pick = container.optString("source") == "materialized_personal_recommendation") + cast = castMembers, related = related, episodes = episodes.filter { it.optString("seriesTitle") == container.getString("title") && it.optInt("seasonNumber") == selectedSeason }.map { item(it, person) }, + connection = TitleConnection.SignedIn, pick = container.optString("source") == "materialized_personal_recommendation", selectedSeason = selectedSeason) } /** Only real household credits are retained; prototype filler is never promoted to filmography. */ @@ -121,4 +129,16 @@ class TitleFixture(private val fixture: JSONObject) { } } +/** Every title entry shares one parsed, read-only debug fixture; release callers cannot load it. */ +@Singleton +class TitleFixtureProvider @Inject constructor(@param:ApplicationContext private val context: Context) { + private val fixture by lazy { + TitleFixture(context.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) + } + suspend fun read(): TitleFixture = withContext(Dispatchers.IO) { + check(BuildConfig.DEBUG) { "Title fixtures are debug-only" } + fixture + } +} + private fun JSONArray.objects(): List = List(length()) { getJSONObject(it) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index c741253a..aa893aca 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -177,7 +177,7 @@ fun WolfskinApp( val route = when (destination) { is Destination.Home -> "home" is Destination.Wolfskin -> destination.route - is Destination.MediaItem -> titleRoute(destination.itemId.toString()) + is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString()) else -> "pushed" } LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index 4130d734..0d714dd9 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -38,6 +38,8 @@ import java.util.Locale import java.util.UUID import javax.inject.Inject +data class PersonRead(val person: TitlePerson, val credits: List, val savedAt: String? = null) + /** Jellyfin SDK changes are confined to this boundary; title UI sees immutable screen data only. */ class TitleReadFacade @Inject constructor( private val api: ApiClient, @@ -59,23 +61,23 @@ class TitleReadFacade @Inject constructor( val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items")) val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } } build(rows, numbers, saved.optInt("selectedSeason"), saved.getString("watch"), saved.optString("target").takeIf { it.isNotEmpty() }, preferences) - .copy(savedAt = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified())), - trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }) + .copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }) } catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null } } /** An episode opens its series with that exact episode as both play and watched target. */ suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) { + val file = cacheFile(id) val opened = api.userLibraryApi.getItem(id).content val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content else opened val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList() - val selectedNumber = season ?: opened.parentIndexNumber ?: 1 + val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else { + api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content } + } + val selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull() val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = fields)).content.items.orEmpty() }.orEmpty() - val target = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else { - api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content } - ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE } - } + val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE } val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id } val numbers = seasons.mapNotNull { it.indexNumber } val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences) @@ -84,7 +86,6 @@ class TitleReadFacade @Inject constructor( val saved = JSONObject().put("items", json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) .put("seasonNumbers", org.json.JSONArray(numbers)).put("selectedSeason", selected?.indexNumber).put("watch", opened.id.toString()) .put("target", target?.id?.toString().orEmpty()).put("trailer", trailer.orEmpty()) - val file = cacheFile(id) file.parentFile?.mkdirs() try { file.writeText(saved.toString()) } catch (_: java.io.IOException) { /* Playback remains available when the disk cannot save a copy. */ } result.copy(trailerUrl = trailer) @@ -110,15 +111,38 @@ class TitleReadFacade @Inject constructor( Unit } + /** A person keeps the same server/user-scoped last good copy as a title. */ + suspend fun readPersonSaved(id: UUID): PersonRead? = withContext(Dispatchers.IO) { + val file = personCacheFile(id) + if (!file.exists()) return@withContext null + try { + val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), file.readText()) + personData(id, rows.first(), rows.drop(1)).copy(savedAt = savedTime(file)) + } catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } + } + /** Held performer credits are read continuously; a signed-out TV never falls back to Seerr. */ - suspend fun readPerson(id: UUID, start: Int): Pair> = withContext(Dispatchers.IO) { - val person = api.userLibraryApi.getItem(id).content + suspend fun readPerson(id: UUID, start: Int): PersonRead = withContext(Dispatchers.IO) { + val file = personCacheFile(id) + val previous = if (start > 0 && file.exists()) { + try { json.decodeFromString(ListSerializer(BaseItemDto.serializer()), file.readText()) } + catch (_: java.io.IOException) { emptyList() } catch (_: IllegalArgumentException) { emptyList() } + } else emptyList() + val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200, sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty() - TitlePerson(id.toString(), person.name.orEmpty(), portrait = images.getItemImageUrl(id, ImageType.PRIMARY, maxWidth = 240, maxHeight = 240), biography = person.overview.orEmpty()) to credits.map { mapItem(it) } + val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id } + file.parentFile?.mkdirs() + try { file.writeText(json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) } + catch (_: java.io.IOException) { /* The readable grid does not disappear when saving fails. */ } + personData(id, person, credits) } + private fun personData(id: UUID, person: BaseItemDto, credits: List): 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, seasonNumbers: List, selectedSeason: Int?, watchId: String, targetId: String?, preferences: UserPreferences): TitlePageData { val item = rows.first() val target = rows.firstOrNull { it.id.toString() == targetId } @@ -139,7 +163,7 @@ class TitleReadFacade @Inject constructor( TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) }) }, episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it) }, - connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers) + connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason) } private fun mapItem(item: BaseItemDto): TitleItem = TitleItem( @@ -152,4 +176,6 @@ class TitleReadFacade @Inject constructor( ) private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json") + private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json") + private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified())) } diff --git a/wolfskin-design/generate_tokens.py b/wolfskin-design/generate_tokens.py index 37039866..a62239e4 100755 --- a/wolfskin-design/generate_tokens.py +++ b/wolfskin-design/generate_tokens.py @@ -86,6 +86,8 @@ def render_tokens(tokens: dict) -> tuple[str, str]: elif isinstance(token.get("dp"), dict): for suffix, dimension in token["dp"].items(): lines.append(f" val {name}{suffix[0].upper() + suffix[1:]} = {number(dimension)}.dp") + if kind == "card": + lines.append(f" const val {name}Slots = {token['fullSlotsPerRow']}") elif group_name == "image" and isinstance(token.get("px"), list): for suffix, dimension in zip(("Width", "Height"), token["px"]): lines.append(f" const val {name}{suffix} = {int(dimension)}") diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt index 7131be1b..1e8ce7d7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt @@ -4,6 +4,11 @@ import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.remember +import androidx.compose.ui.draw.clipToBounds import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -15,8 +20,14 @@ import androidx.tv.material3.Text @Composable fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) { val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "caption-fade") + val parts = remember(facts) { facts.split(" · ") } Column(modifier.graphicsLayer { this.alpha = alpha }) { Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(facts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(Modifier.fillMaxWidth().clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Caption.factsGap)) { + parts.forEachIndexed { index, part -> + if (index > 0) Text("·", color = WolfskinTokens.Color.muted.copy(alpha = WolfskinTokens.Caption.separatorOpacity), style = WolfskinTokens.Type.caption) + Text(part, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index 543ac321..ef4bb613 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -7,6 +7,11 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.core.spring import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -30,6 +35,7 @@ import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S +import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V /** Blob bounds retain their measurement while their draw coordinates spring between targets. */ data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp) @@ -50,7 +56,7 @@ fun navBoxes(tabs: List): List { /** There is one blob, including when focus wraps around the nav avatar. */ @Composable -fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) { +fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier, savedAt: String? = null) { val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) } val labelFade = remember { tween(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) } val index = if (state.navFocused) state.navIndex else state.currentTab @@ -94,6 +100,15 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) } } } + if (savedAt != null) { + Row(Modifier.align(Alignment.TopEnd) + .offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2) + .height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button)) + .padding(horizontal = V.side), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) { + TitleGlyph("wifi", C.warn, Modifier.size(V.icon)) + Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption) + } + } val avatar = boxes.last() Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, Modifier.offset(avatar.left, avatar.top) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index 07240437..e1640b81 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -1,5 +1,6 @@ package org.justinvisser.wolfskin.design +import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,6 +20,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,8 +39,16 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D /** Filmography retains a real credit key and requests more credits before focus reaches the tail. */ -class PersonState(val person: TitlePerson, initialCredits: List, private val onAction: (TitleAction) -> Unit, private val onReadMore: () -> Unit = {}) { +class PersonState(initialPerson: TitlePerson, initialCredits: List, private val onAction: (TitleAction) -> Unit, private val onReadMore: () -> Unit = {}) { + var person by mutableStateOf(initialPerson) + private set var credits by mutableStateOf(initialCredits) + var savedAt by mutableStateOf(null) + var toast by mutableStateOf(null) + fun update(actor: TitlePerson, titles: List, saved: String? = null) { + person = actor; credits = titles; savedAt = saved + index = index.coerceAtMost((credits.size - 1).coerceAtLeast(0)) + } var index by mutableStateOf(0) private var armed: String? = null @@ -46,7 +57,11 @@ class PersonState(val person: TitlePerson, initialCredits: List, priv /** The first grid row exits upward into the existing shell nav. */ fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean { - if (credits.isEmpty()) { if (down && key == ShellKey.Back) onAction(TitleAction.Pop); return true } + if (credits.isEmpty()) { + if (down && key == ShellKey.Back) onAction(TitleAction.Pop) + else if (down && key == ShellKey.Up) shell.focusNav(shell.currentTab) + return true + } val columns = WolfskinTokens.Behaviour.gridColumns.toInt() if (key == ShellKey.Ok) { if (repeat > 0) return true @@ -72,8 +87,11 @@ class PersonState(val person: TitlePerson, initialCredits: List, priv @Composable fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } } - androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) { - ShellSurface(shell, modifier, handlesContentKeys = false) { PersonPage(state, shell, it) } + Box(modifier.fillMaxSize().background(C.bg)) { + androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) { + ShellSurface(shell, handlesContentKeys = false, savedAt = state.savedAt) { PersonPage(state, shell, it) } + } + if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } } } @@ -81,13 +99,32 @@ fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Mo private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { val density = LocalDensity.current val list = shell.view.listState + var wheelPending by remember { mutableStateOf(false) } val columns = WolfskinTokens.Behaviour.gridColumns.toInt() LaunchedEffect(state.index / columns, shell.navFocused) { - if (shell.navFocused || state.index < columns) list.animateScrollToItem(0) - else list.animateScrollToItem(2 + state.index / columns, -with(density) { L.pinnedLine.roundToPx() }) + val target = if (shell.navFocused || state.index < columns) 0f + else with(density) { (L.row_phead + L.row_gh + D.gridHeight * (state.index / columns) - L.pinnedLine).toPx() } + val first = list.firstVisibleItemIndex + val before = when (first) { + 0 -> WolfskinTokens.Shell.zero + 1 -> L.row_phead + else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2) + } + val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset + if (target != current) list.animateScrollBy(target - current, titleFrameScrollSpec) } Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" } - .onPreviewKeyEvent { event -> readShellKey(event)?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false }.focusable()) { + .titleWheel { wheelPending = true; shell.navFocused = false } + .onPreviewKeyEvent { event -> + val key = readShellKey(event) + if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) { + val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index >= 2 && it.key != "end-padding" && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() } } + state.index = ((visible?.index?.minus(2) ?: 0) * columns).coerceAtMost((state.credits.size - 1).coerceAtLeast(0)) + wheelPending = false + if (key != ShellKey.Ok) return@onPreviewKeyEvent true + } + key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false + }.focusable()) { LazyColumn(state = list, modifier = Modifier.fillMaxSize()) { item(key = "person-header", contentType = "header") { Box(Modifier.height(L.row_phead)) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index ebce3320..8765ec54 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -58,6 +58,7 @@ fun ShellSurface( modifier: Modifier = Modifier, handlesContentKeys: Boolean = true, settingsOpenRequests: Int? = null, + savedAt: String? = null, content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) }, ) { val focus = remember { FocusRequester() } @@ -86,7 +87,7 @@ fun ShellSurface( WhosWatching(state) } else { if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) - NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) + NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2, savedAt = savedAt) if (state.requestsVisible) { Box(Modifier.fillMaxSize().background(C.sheetScrim)) { Column(Modifier.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt index c80819f5..46a2f204 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -19,7 +20,7 @@ import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.toArgb import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.Dp import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -30,9 +31,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.tv.material3.Text import coil3.SingletonImageLoader import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.allowRgb565 -import coil3.request.crossfade import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D @@ -48,7 +46,7 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo else if (path != null) { val context = LocalContext.current val request = remember(context, path, width, height, poster) { - ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build() + titleImageRequest(context, path, width, height, poster) } AsyncImage(model = request, imageLoader = LocalWolfskinImageLoader.current ?: SingletonImageLoader.get(context), contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter) @@ -58,11 +56,11 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo /** Initials follow the same title-hashed hue as the locked art fallback. */ @Composable fun PersonPortrait(person: TitlePerson, modifier: Modifier = Modifier, large: Boolean = false) { - val brush = remember(person.name) { initialsBrush(person.name) } - Box(modifier.background(brush), contentAlignment = Alignment.Center) { - if (person.portrait != null) TitleArtwork(person.portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight, Modifier.fillMaxSize()) - else Text(person.name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).joinToString("") { it.take(1) }.uppercase(), - color = C.fg, style = if (large) D.personInitialSize else D.initialSize, textAlign = TextAlign.Center) + val initials = remember(person.name) { person.name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).joinToString("") { it.take(1) }.uppercase() } + val size = if (large) (L.personPortrait.value * 2).toInt() else WolfskinTokens.Image.castRequestWidth + Box(modifier.titleFallbackBackground(person.name, D.personFallbackGradient), contentAlignment = Alignment.Center) { + Text(initials, color = D.initialsColor, style = if (large) D.personInitialSize else D.initialSize, textAlign = TextAlign.Center) + if (person.portrait != null) TitleArtwork(person.portrait, size, size, Modifier.fillMaxSize()) } } @@ -105,10 +103,43 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) { } } -private fun initialsBrush(name: String): Brush { +/** Fallback art stays behind an image, so a failed decode never leaves an empty card. */ +@Composable +fun TitleCardArtwork(item: TitleItem, tile: Boolean) { + Box(Modifier.fillMaxSize().titleFallbackBackground(item.seriesName ?: item.name, D.cardFallbackGradient)) { + TitleGlyph(if (item.kind == "movie") "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph)) + if (!tile) Text(item.name, color = C.fg, style = D.fallbackTitle, maxLines = 4, + modifier = Modifier.align(Alignment.BottomStart).padding(D.fallbackInset)) + if (tile && item.isPosterBackdrop && item.poster != null) { + val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply { + for (index in 0 until 15) values[index] *= D.tileAmbientBrightness + }) } + TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().blur(D.tileAmbientBlur), colorFilter = filter) + val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2 + TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(), + Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).clip(RoundedCornerShape(D.tileSheetRadius)), poster = true) + } else { + TitleArtwork(if (tile) item.backdrop else item.poster, + if (tile) WolfskinTokens.Image.tileRequestWidth else WolfskinTokens.Image.posterRequestWidth, + if (tile) WolfskinTokens.Image.tileRequestHeight else WolfskinTokens.Image.posterRequestHeight, + Modifier.fillMaxSize(), poster = !tile) + } + } +} + +private fun Modifier.titleFallbackBackground(name: String, token: String): Modifier = drawWithCache { var hue = 0 name.forEach { hue = (hue * 31 + it.code) % 360 } - return Brush.linearGradient(listOf(Color.hsl(hue.toFloat(), .24f, .30f), Color.hsl(hue.toFloat(), .26f, .14f))) + val values = Regex("([\\d.]+)%").findAll(token).map { it.groupValues[1].toFloat() / 100 }.toList() + val angle = Math.toRadians(Regex("(\\d+)deg").find(token)!!.groupValues[1].toDouble()) + val dx = kotlin.math.sin(angle).toFloat() + val dy = -kotlin.math.cos(angle).toFloat() + val half = (kotlin.math.abs(size.width * dx) + kotlin.math.abs(size.height * dy)) / 2 + val vector = Offset(dx * half, dy * half) + val middle = Offset(size.width / 2, size.height / 2) + val brush = Brush.linearGradient(listOf(Color.hsl(hue.toFloat(), values[0], values[1]), Color.hsl(hue.toFloat(), values[2], values[3])), + start = middle - vector, end = middle + vector) + onDrawBehind { drawRect(brush) } } private fun Modifier.titleAmbientBackground(): Modifier = drawWithCache { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt index 5c4d2947..f02c00af 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt @@ -24,9 +24,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D /** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */ @Composable -fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { +fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, onClick: () -> Unit) { val focusOwner = LocalTitleFocusOwner.current - val background = if (option.primary) C.fg else if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary + val background = if (option.primary) C.fg else if (overArtwork) { + if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary + } else if (focused) C.buttonSecondaryFocused else C.buttonSecondary val foreground = if (option.primary) C.onFocus else C.fg val shape = RoundedCornerShape(WolfskinTokens.Radius.button) val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX) @@ -57,6 +59,8 @@ fun TitleGlyph(name: String, color: androidx.compose.ui.graphics.Color, modifier "clock" -> R.drawable.title_clock "users" -> R.drawable.title_users "tv" -> R.drawable.title_tv + "film" -> R.drawable.title_film + "alert" -> R.drawable.title_alert "wifi" -> R.drawable.title_wifi "check" -> R.drawable.title_check "sparkle" -> R.drawable.title_sparkle diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt index 96c047f1..401352fd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -1,5 +1,7 @@ package org.justinvisser.wolfskin.design +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInRoot import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -77,7 +79,9 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie } } Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index, onFocus = { state.focus("header", index) }) { state.click("header", index) } } + state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index, + Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) }, + onFocus = { state.focus("header", index) }) { state.click("header", index) } } } if (state.options.any { it.icon }) { val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt index f9cf3bdf..2b104c5f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt @@ -86,6 +86,8 @@ data class TitlePageData( val pick: Boolean = false, val savedAt: String? = null, val seasonNumbers: List = 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. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt index 1b10f1f1..0f8891fe 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt @@ -137,7 +137,7 @@ private fun WatchedConfirmation(state: TitleState) { SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.") Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } - TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }) { state.clickSheet(1) } + TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) } } } @@ -158,6 +158,6 @@ private fun HiddenReceipt(state: TitleState) { } Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } - TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }) { state.clickSheet(1) } + TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index da2aae71..156c9588 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -1,5 +1,7 @@ package org.justinvisser.wolfskin.design +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInRoot import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -24,6 +26,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.focus.FocusRequester @@ -44,22 +52,28 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D /** The shared shell remains authoritative; title sheets overlay it and trap all input. */ @Composable fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { - LaunchedEffect(state.toast) { - if (state.toast != null) { delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } - } Box(modifier.fillMaxSize()) { val focusOwner = remember(shell) { { shell.navFocused = false } } androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) { - ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) } + ShellSurface(shell, handlesContentKeys = false, savedAt = state.data.savedAt) { TitlePage(state, shell, it) } } if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell) - if (!shell.pickerVisible) state.toast?.let { message -> - Row(Modifier.align(Alignment.TopEnd).padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth) - .background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop), - horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { - TitleGlyph("wifi", C.warn, Modifier.size(L.buttonIcon)) - Text(message, color = C.fg, style = WolfskinTokens.Type.body) - } + if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } + } +} + +/** Playback and person reads share one calm, timed notice above the persistent nav. */ +@Composable +internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDismiss: () -> Unit) { + LaunchedEffect(message) { + if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() } + } + if (message != null) { + Row(modifier.padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth) + .background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop), + horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { + TitleGlyph("alert", C.warn, Modifier.size(L.buttonIcon)) + Text(message, color = C.fg, style = WolfskinTokens.Type.body) } } } @@ -69,6 +83,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { val density = LocalDensity.current val list = shell.view.listState + var wheelPending by remember { mutableStateOf(false) } val focus = remember { FocusRequester() } LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) { if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() @@ -78,31 +93,52 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie add("header") if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) { add("seasons") - if (state.data.episodes.isNotEmpty()) add("episodes") + if (state.data.episodesStatus == TitleReadStatus.Loading || state.data.episodes.isNotEmpty()) add("episodes") } if (state.data.castStatus == TitleReadStatus.Loading || state.data.cast.isNotEmpty()) add("cast") if (state.data.related.isNotEmpty()) add("related") } } - LaunchedEffect(state.row, shell.navFocused, state.data.status) { + val rowOffsets = remember(displayedRows, density) { + FloatArray(displayedRows.size + 1).apply { + displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row).toPx() } } + } + } + val position by remember(list, rowOffsets) { derivedStateOf { + rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset + } } + LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows) { if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab) val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0) - val y = displayedRows.take(index).fold(WolfskinTokens.Shell.zero) { total, row -> total + titleRowHeight(row) } - val height = titleRowHeight(state.row) - if (index == 0 || (y < L.windowAhead && y + height <= WolfskinTokens.Shell.stageHeight - L.safeTopPicker)) list.animateScrollToItem(0) - else list.animateScrollToItem(index, -with(density) { L.pinnedLine.roundToPx() }) + val y = rowOffsets[index] + val height = with(density) { titleRowHeight(state.row).toPx() } + val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f + else y - with(density) { L.pinnedLine.toPx() } + if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec) } - val dim by remember { derivedStateOf { - val before = displayedRows.take(list.firstVisibleItemIndex).sumOf { with(density) { titleRowHeight(it).toPx().toDouble() } } - ((before + list.firstVisibleItemScrollOffset) / D.dimScrollPx).toFloat().coerceAtMost(D.dimMaximum) - } } + val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } } + val heroVisible by remember(rowOffsets, list, density) { derivedStateOf { position < with(density) { WolfskinTokens.Shell.stageHeight.toPx() } } } + val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } } + val nextArtRow = remember(displayedRows, lastVisible) { if (lastVisible < 0) null else displayedRows.drop(lastVisible + 1).firstOrNull { it == "cast" || it == "episodes" || it == "related" } } + TitleRowPrefetch(state.data, nextArtRow) Box(modifier.focusRequester(focus).testTag("title-page").semantics { stateDescription = "${state.data.item.id}:${state.row}:${state.column}:${state.sheet}" } + .titleWheel { if (state.data.status == TitleReadStatus.Ready) { wheelPending = true; shell.navFocused = false } } .onPreviewKeyEvent { event -> val key = readShellKey(event) + if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) { + val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { + it.key in state.rows && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() } + }?.key as? String ?: "header" + state.focus(visible, state.columns[visible] ?: 0) + wheelPending = false + if (key != ShellKey.Ok) return@onPreviewKeyEvent true + } key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) }.focusable()) { - TitleBackdrop(state.data.item, Modifier.fillMaxSize()) - Box(Modifier.fillMaxSize().background(C.bg.copy(alpha = dim))) + if (heroVisible) { + TitleBackdrop(state.data.item, Modifier.fillMaxSize()) + Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg)) + } else Box(Modifier.fillMaxSize().background(C.bg)) if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state) else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) { displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") { @@ -140,6 +176,7 @@ private fun TitleSeasons(state: TitleState, active: Boolean) { val selected = state.season == number val shape = RoundedCornerShape(WolfskinTokens.Radius.chip) Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl) + .onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) } .background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape) .then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier) .clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) { @@ -151,9 +188,17 @@ private fun TitleSeasons(state: TitleState, active: Boolean) { @Composable private fun TitleReadError(state: TitleState) { - Column(Modifier.offset(L.safeSide, L.navBand).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(D.overviewGap), verticalArrangement = Arrangement.spacedBy(L.buttonGap)) { - Text("This title can’t be read", color = C.warn, style = WolfskinTokens.Type.stateTitle) - Text("Conductarr didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body) - Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }) { state.click("header", index) } } } + Column(Modifier.offset(L.safeSide, D.errorTop).width(L.stateWidth) + .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .padding(horizontal = D.errorSide, vertical = D.errorVertical)) { + Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { + TitleGlyph("alert", C.warn, Modifier.size(D.receiptIcon)) + Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = D.errorTitle) + } + Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.errorBodyBottom)) + Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }, overArtwork = false) { state.click("header", index) } } } } } + +internal val titleFrameScrollSpec = tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePointer.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePointer.kt index 82bdaf29..ab763b8a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePointer.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePointer.kt @@ -21,7 +21,20 @@ fun Modifier.titlePointerFocus(onFocus: (() -> Unit)?): Modifier { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() - if (event.type == PointerEventType.Enter) { owner?.invoke(); latest?.invoke() } + if (event.type == PointerEventType.Enter) { owner?.invoke(); latest() } + } + } + } +} + +/** Observe wheel input without consuming the frame's native scroll gesture. */ +@Composable +fun Modifier.titleWheel(onScroll: () -> Unit): Modifier { + val latest by rememberUpdatedState(onScroll) + return pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + if (awaitPointerEvent().type == PointerEventType.Scroll) latest() } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleRating.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleRating.kt index 006d9cd0..ae18cf2c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleRating.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleRating.kt @@ -32,13 +32,15 @@ fun formatTitleRating(rating: TitleRating, locale: Locale = Locale.getDefault()) /** A full facts-line set never shrinks or waits for focused-card dwell. */ @Composable -fun TitleRatings(ratings: List, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.button) { +fun TitleRatings(ratings: List, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.button, plate: Boolean = false) { val density = LocalDensity.current val markHeight = with(density) { style.fontSize.toDp() } - Row(modifier, horizontalArrangement = Arrangement.spacedBy(D.ratingGap), verticalAlignment = Alignment.CenterVertically) { + val gap = if (plate) with(density) { (style.fontSize * D.ratingPlateEntryEm).toDp() } else D.ratingGap + val markGap = if (plate) with(density) { (style.fontSize * D.ratingPlateMarkEm).toDp() } else D.ratingMarkGap + Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) { ratings.forEach { rating -> - Row(horizontalArrangement = Arrangement.spacedBy(D.ratingMarkGap), verticalAlignment = Alignment.CenterVertically) { - if (rating.episode) Text("Ep", color = C.muted, style = style) + Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) { + if (rating.episode) Text("Ep", color = C.muted, style = style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight)) val resource = when (rating.mark) { "rotten" -> R.drawable.ic_rotten_tomatoes_rotten "upright" -> R.drawable.ic_rt_upright @@ -47,8 +49,8 @@ fun TitleRatings(ratings: List, modifier: Modifier = Modifier, styl "tmdb" -> R.drawable.ic_rating_tmdb else -> R.drawable.ic_rotten_tomatoes_fresh } - Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") 1.8f else 1f, markHeight), - alpha = if (rating.mark == "tomato") .55f else 1f, + Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f, markHeight), + alpha = if (rating.mark == "tomato") D.mutedTomatoOpacity else 1f, colorFilter = if (rating.mark == "tomato") ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null) Text(formatTitleRating(rating), color = C.fg, style = style.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold), maxLines = 1) } @@ -68,16 +70,27 @@ fun TitleRatingPlate(ratings: List, modifier: Modifier = Modifier) val fit = remember(ratings, width, density, Locale.getDefault()) { var size = WolfskinTokens.Type.ratingPlate.fontSize.value var count = ratings.size + val numbers = ratings.map { formatTitleRating(it) } fun measured(): Float { - val text = ratings.take(count).joinToString("") { (if (it.episode) "Ep " else "") + formatTitleRating(it) } - val textWidth = measurer.measure(text, WolfskinTokens.Type.button.copy(fontSize = TextUnit(size, TextUnitType.Sp))).size.width - val marks = ratings.take(count).sumOf { if (it.source == "imdb" || it.source == "tmdb") 1.8 else 1.0 }.toFloat() - return textWidth + with(density) { TextUnit(size * marks, TextUnitType.Sp).toPx() + D.ratingMarkGap.toPx() * count + D.ratingGap.toPx() * (count - 1) } + val fontSize = TextUnit(size, TextUnitType.Sp) + val style = WolfskinTokens.Type.button.copy(fontSize = fontSize, fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) + val markGap = with(density) { (fontSize * D.ratingPlateMarkEm).toPx() } + val entryGap = with(density) { (fontSize * D.ratingPlateEntryEm).toPx() } + var total = 0f + for (index in 0 until count) { + val rating = ratings[index] + total += measurer.measure(numbers[index], style, maxLines = 1).size.width + total += with(density) { fontSize.toPx() } * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f + total += markGap + if (rating.episode) total += measurer.measure("Ep", style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), maxLines = 1).size.width + markGap + if (index > 0) total += entryGap + } + return total } - while (size > WolfskinTokens.Type.caption.fontSize.value && measured() > width) size = (size - .25f).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value) + while (size > WolfskinTokens.Type.caption.fontSize.value && measured() > width) size = (size - D.ratingPlateStepSp).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value) while (count > 1 && measured() > width) count-- count to size } - TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.button.copy(fontSize = TextUnit(fit.second, TextUnitType.Sp))) + TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.button.copy(fontSize = TextUnit(fit.second, TextUnitType.Sp)), plate = true) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index f1f1b8ce..d0e88039 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -1,5 +1,13 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -15,6 +23,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInRoot import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape @@ -39,17 +50,19 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D fun TitleCast(state: TitleState, active: Boolean) { val track = rememberLazyListState() val focusOwner = LocalTitleFocusOwner.current - LaunchedEffect(state.column, active) { if (active) track.animateScrollToItem((state.column - 5).coerceAtLeast(0)) } + val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() } + LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) } Column(Modifier.height(D.castHeight)) { Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) { if (state.data.castStatus == TitleReadStatus.Loading) { - items(7, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) { + items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) { Box(Modifier.size(L.castCircle).background(C.surface, CircleShape)) } } else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person -> val focused = active && state.column == index Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f) + .onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) } .titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) { PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape)) Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) { @@ -70,13 +83,20 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH val track = rememberLazyListState() - val visible = if (episodes) 4 else 6 - LaunchedEffect(state.column, active) { if (active) track.animateScrollToItem((state.column - (visible - 2)).coerceAtLeast(0)) } + val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots + val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() } + LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) } Column(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) { Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { - itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item -> - TitlePoster(item, active && state.column == index, Modifier.width(width), episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) } + if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) { + items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { + Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) + } + } else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item -> + TitlePoster(item, active && state.column == index, Modifier.width(width) + .onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) }, + episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) } } } } @@ -92,24 +112,46 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier Box(Modifier.size(width, height).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent) .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { - TitleArtwork(if (tile) item.backdrop ?: item.poster else item.poster, (width.value * 2).toInt(), (height.value * 2).toInt(), Modifier.fillMaxSize(), !tile) - if (item.poster == null && item.backdrop == null) Text(item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 4, modifier = Modifier.padding(D.lineGap)) + TitleCardArtwork(item, tile) if (tile) { - Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg))).padding(D.lineGap)) { + Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) + .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(if (item.kind == "episode") "${item.episodeLabel} · ${item.name}" else "${if (item.kind == "movie") "Movie" else "Series"} · ${item.year ?: ""}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) } } - if (item.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { + val progress = if (item.runtimeSeconds > 0) item.resumeSeconds.toFloat() / item.runtimeSeconds else 0f + if (progress > 0f && progress < 1f) { + Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = if (tile) D.tileProgressBottom else D.cardProgressBottom) + .fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) { + Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent)) + } + } + if (item.held && !tile) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { TitleGlyph("check", C.onFocus, Modifier.size(L.buttonIcon)) } - if (focused && ratingsVisible) TitleRatingPlate(item.ratings, Modifier.fillMaxWidth()) + AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) { + TitleRatingPlate(item.ratings, Modifier.fillMaxWidth()) + } } if (!tile) { Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) { - Caption(item.name, listOfNotNull(item.character?.let { "as $it" }, item.year?.toString(), if (item.kind == "movie") item.runtime else if (item.seasons > 0) "${item.seasons} seasons" else "Series", if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }).joinToString(" · "), focused, + Caption(item.name, listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), if (item.kind == "movie") item.runtime.takeIf { item.runtimeSeconds > 0 } ?: "Movie" else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }).joinToString(" · "), focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth)) } } } } + +/** Keep a full lookahead card, but do not recenter a row when reversing direction. */ +private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float) { + val first = firstVisibleItemIndex + val next = when { + column < first -> column + column >= first + slots - 1 -> (column - slots + 2).coerceAtLeast(0) + else -> first + } + if (next != first) animateScrollBy((next - first) * pitch - firstVisibleItemScrollOffset, titleTrackScrollSpec) +} + +private val titleTrackScrollSpec = tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index 420212e0..a2bf2187 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -17,10 +17,14 @@ class TitleState( var row by mutableStateOf("header") var column by mutableStateOf(0) val columns = mutableStateMapOf() + private val centers = HashMap>() + 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(null) private set var sheetIndex by mutableStateOf(0) - var season by mutableStateOf(initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1) + var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1) var versionId by mutableStateOf(initial.selectedVersion) var audioIndex by mutableStateOf(initial.selectedAudio) var subtitleIndex by mutableStateOf(initial.selectedSubtitle) @@ -37,7 +41,7 @@ class TitleState( if (data.status != TitleReadStatus.Ready) return@buildList if (data.item.held && data.item.kind == "tv" && data.item.seasons > 0) { add("seasons") - if (data.episodes.isNotEmpty()) add("episodes") + if (data.episodesStatus == TitleReadStatus.Ready && data.episodes.isNotEmpty()) add("episodes") } if (data.castStatus == TitleReadStatus.Ready && data.cast.isNotEmpty()) add("cast") if (data.related.isNotEmpty()) add("related") @@ -47,12 +51,16 @@ class TitleState( /** Refresh keeps the selected row and file when they still exist. */ fun update(value: TitlePageData) { val wasLoading = data.status == TitleReadStatus.Loading + val pendingWatched = watched != data.watchTarget.watched + val pendingVersion = versionId != data.selectedVersion + val pendingAudio = audioIndex != data.selectedAudio + val pendingSubtitle = subtitleIndex != data.selectedSubtitle data = value - if (wasLoading) season = value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1 - watched = value.watchTarget.watched - versionId = value.selectedVersion - audioIndex = value.selectedAudio - subtitleIndex = value.selectedSubtitle + if (wasLoading || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1 + if (!pendingWatched) watched = value.watchTarget.watched + if (!pendingVersion) versionId = value.selectedVersion + if (!pendingAudio) audioIndex = value.selectedAudio + if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle if (row !in rows) { row = "header"; column = 0 } column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0)) } @@ -71,8 +79,29 @@ class TitleState( } - /** Film and episode toggles are optimistic; the facade restores this value on failure. */ - fun restoreWatched(value: Boolean) { watched = value } + /** Keep the last confirmed selections separate from immediate, optimistic UI feedback. */ + fun confirmPlayback(action: TitleAction) { + data = when (action) { + is TitleAction.Version -> data.copy(selectedVersion = action.version.id, selectedAudio = null, selectedSubtitle = -1) + is TitleAction.Track -> data.copy(selectedVersion = action.version.id, selectedAudio = action.audio, selectedSubtitle = action.subtitle) + is TitleAction.Watched -> data.copy(watchTarget = data.watchTarget.copy(watched = action.watched)) + else -> data + } + } + + /** A failed older write cannot undo a newer selection still waiting to save. */ + fun rejectPlayback(action: TitleAction) { + when (action) { + is TitleAction.Version -> if (versionId == action.version.id) { + versionId = data.selectedVersion; audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle + } + is TitleAction.Track -> if (audioIndex == action.audio && subtitleIndex == action.subtitle) { + audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle + } + is TitleAction.Watched -> if (watched == action.watched) watched = data.watchTarget.watched + else -> Unit + } + } /** Key-up can only activate the same card that armed the first key-down. */ fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean { @@ -86,7 +115,7 @@ class TitleState( } if (repeat > 0 || okHeld) return true okHeld = true - if (sheet == null && row in listOf("cast", "related", "episodes")) { + if (sheet == null && (row == "cast" || row == "related" || row == "episodes")) { armedCard = "$row:$column" return true } @@ -115,7 +144,9 @@ class TitleState( private fun moveRow(delta: Int) { val index = (rows.indexOf(row) + delta).coerceIn(0, rows.lastIndex) val next = rows[index] - focus(next, (columns[next] ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0))) + val x = centers[row]?.get(column) + val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null + focus(next, (columns[next] ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0))) } private fun rowSize(id: String): Int = when (id) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt index 8c300989..78e0439b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt @@ -1,7 +1,44 @@ package org.justinvisser.wolfskin.design import androidx.compose.runtime.compositionLocalOf +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext +import coil3.request.ImageRequest +import coil3.request.Disposable +import coil3.request.allowRgb565 +import coil3.request.crossfade import coil3.ImageLoader /** Owned screens share one bounded loader; standalone fixtures can use Coil's local asset loader. */ val LocalWolfskinImageLoader = compositionLocalOf { 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() + 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() } } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 92faa530..a78153ad 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -166,26 +166,31 @@ object WolfskinTokens { val posterGap = 12.0.dp val posterPitch = 140.0.dp val posterCaptionBand = 38.0.dp + const val posterSlots = 6 val tileW = 208.0.dp val tileH = 117.0.dp val tileGap = 12.0.dp val tilePitch = 220.0.dp val tileCaptionBand = 0.0.dp + const val tileSlots = 4 val wideW = 280.0.dp val wideH = 157.5.dp val wideGap = 12.0.dp val widePitch = 292.0.dp val wideCaptionBand = 0.0.dp + const val wideSlots = 3 val entryW = 128.0.dp val entryH = 72.0.dp val entryGap = 12.0.dp val entryPitch = 140.0.dp val entryCaptionBand = 0.0.dp + const val entrySlots = 6 val personW = 100.0.dp val personH = 145.0.dp val personGap = 14.0.dp val personPitch = 114.0.dp val personCaptionBand = 0.0.dp + const val personSlots = 7 } object Focus { const val scaleCard = 1.08f @@ -372,5 +377,44 @@ object WolfskinTokens { const val heroAmbientBackground = "radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg))" const val dimScrollPx = 1400.0f const val dimMaximum = 0.7f + val errorTop = 85.0.dp + val errorSide = 20.0.dp + val errorVertical = 17.0.dp + val errorHeadingBottom = 4.0.dp + val errorBodyBottom = 12.0.dp + val errorTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight.Bold, lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em) + val fallbackGlyph = 22.0.dp + val fallbackInset = 11.0.dp + val tileSheetInset = 9.0.dp + val tileSheetLeft = 11.0.dp + val tileSheetRadius = 5.0.dp + val tileAmbientBlur = 13.0.dp + const val tileAmbientBrightness = 0.5f + const val cardFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))" + const val personFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))" + val initialsColor = ComposeColor(0xD1FFFFFF) + val fallbackGlyphColor = ComposeColor(0x73FFFFFF) + val fallbackTitle = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.099999999999998.sp, letterSpacing = 0.0.em) + val tileOverlayTop = 27.0.dp + val tileOverlaySide = 11.0.dp + val tileOverlayBottom = 9.0.dp + const val cardOverlayOpacity = 0.92f + val cardProgressInset = 9.0.dp + val cardProgressBottom = 6.0.dp + val tileProgressBottom = 5.0.dp + const val ratingPlateEntryEm = 0.64f + const val ratingPlateMarkEm = 0.29f + } + object SavedIndicator { + val height = 26.0.dp + val side = 10.0.dp + val gap = 5.0.dp + val icon = 14.0.dp + val border = 1.0.dp + const val borderOpacity = 0.5f + } + object Caption { + const val separatorOpacity = 0.4f + val factsGap = 9.0.dp } } diff --git a/wolfskin-design/src/main/res/drawable/title_alert.xml b/wolfskin-design/src/main/res/drawable/title_alert.xml new file mode 100644 index 00000000..5ab53448 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/title_alert.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/title_film.xml b/wolfskin-design/src/main/res/drawable/title_film.xml new file mode 100644 index 00000000..3544afeb --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/title_film.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index 112e5c3e..0b7749e7 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -78,6 +78,45 @@ "toastTop": {"dp": 11, "px": 22, "source": "index.html:270"}, "heroAmbientBackground": {"$type": "gradient", "$value": "radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg))", "source": "index.html:94"}, "dimScrollPx": {"$value": 1400, "source": "index.html:1541"}, - "dimMaximum": {"$value": 0.7, "source": "index.html:1541"} + "dimMaximum": {"$value": 0.7, "source": "index.html:1541"}, + "errorTop": {"dp": 85, "px": 170, "source": "index.html:1457,194"}, + "errorSide": {"dp": 20, "px": 40, "source": "index.html:194"}, + "errorVertical": {"dp": 17, "px": 34, "source": "index.html:194"}, + "errorHeadingBottom": {"dp": 4, "px": 8, "source": "index.html:195"}, + "errorBodyBottom": {"dp": 12, "px": 24, "source": "index.html:198"}, + "errorTitle": {"$type": "typography", "sp": 18, "px": 36, "weight": 700, "$value": {"fontWeight": 700}, "source": "index.html:195"}, + "fallbackGlyph": {"dp": 22, "px": 44, "source": "index.html:131"}, + "fallbackInset": {"dp": 11, "px": 22, "source": "index.html:130-131"}, + "tileSheetInset": {"dp": 9, "px": 18, "source": "index.html:128"}, + "tileSheetLeft": {"dp": 11, "px": 22, "source": "index.html:128"}, + "tileSheetRadius": {"dp": 5, "px": 10, "source": "index.html:128"}, + "tileAmbientBlur": {"dp": 13, "px": 26, "source": "index.html:129"}, + "tileAmbientBrightness": {"$value": 0.5, "source": "index.html:129"}, + "cardFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))", "source": "index.html:130"}, + "personFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))", "source": "index.html:288"}, + "initialsColor": {"$type": "color", "$value": "#ffffffd1", "source": "index.html:288"}, + "fallbackGlyphColor": {"$type": "color", "$value": "#ffffff73", "source": "index.html:131"}, + "fallbackTitle": {"$type": "typography", "sp": 14, "px": 28, "weight": 700, "lineHeight": 1.15, "$value": {"fontWeight": 700}, "source": "index.html:132"}, + "tileOverlayTop": {"dp": 27, "px": 54, "source": "index.html:135"}, + "tileOverlaySide": {"dp": 11, "px": 22, "source": "index.html:135"}, + "tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"}, + "cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"}, + "cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"}, + "cardProgressBottom": {"dp": 6, "px": 12, "source": "index.html:138"}, + "tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"}, + "ratingPlateEntryEm": {"$value": 0.64, "source": "index.html:164"}, + "ratingPlateMarkEm": {"$value": 0.29, "source": "index.html:165"} + }, + "savedIndicator": { + "height": {"dp": 26, "px": 52, "source": "index.html:87"}, + "side": {"dp": 10, "px": 20, "source": "index.html:87"}, + "gap": {"dp": 5, "px": 10, "source": "index.html:87"}, + "icon": {"dp": 14, "px": 28, "source": "index.html:88"}, + "border": {"dp": 1, "px": 2, "source": "index.html:87"}, + "borderOpacity": {"$value": 0.5, "source": "index.html:87"} + }, + "caption": { + "separatorOpacity": {"$value": 0.4, "source": "spec.md:4.4; index.html:1207"}, + "factsGap": {"dp": 9, "px": 18, "source": "index.html:154"} } } diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index a93554d4..b7c68f7e 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -246,4 +246,33 @@ --title-toastSide: 28.0px; --title-toastTop: 22.0px; --title-heroAmbientBackground: radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg)); + --title-errorTop: 170.0px; + --title-errorSide: 40.0px; + --title-errorVertical: 34.0px; + --title-errorHeadingBottom: 8.0px; + --title-errorBodyBottom: 24.0px; + --title-errorTitle: 36.0px; + --title-fallbackGlyph: 44.0px; + --title-fallbackInset: 22.0px; + --title-tileSheetInset: 18.0px; + --title-tileSheetLeft: 22.0px; + --title-tileSheetRadius: 10.0px; + --title-tileAmbientBlur: 26.0px; + --title-cardFallbackGradient: linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%)); + --title-personFallbackGradient: linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%)); + --title-initialsColor: #ffffffd1; + --title-fallbackGlyphColor: #ffffff73; + --title-fallbackTitle: 28.0px; + --title-tileOverlayTop: 54.0px; + --title-tileOverlaySide: 22.0px; + --title-tileOverlayBottom: 18.0px; + --title-cardProgressInset: 18.0px; + --title-cardProgressBottom: 12.0px; + --title-tileProgressBottom: 10.0px; + --savedIndicator-height: 52.0px; + --savedIndicator-side: 20.0px; + --savedIndicator-gap: 10.0px; + --savedIndicator-icon: 28.0px; + --savedIndicator-border: 2.0px; + --caption-factsGap: 18.0px; } diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 3b93b4ff..ed0e95f9 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,6 +1,6 @@ -fa375a78bf808451823caa3c23c8aeca5b370310706ae347f978bfe5f2f0eed3 generate_tokens.py +811d6397d71d7771cad62e6745e8d85acd22a60878a4d2719792831de58d2fdb generate_tokens.py 6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json -eea6348a80840947512e7a3aee599a69e91099b87ae7be2035843eb435142fb7 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -b7d9c553420775707607e906582b1d3050459cd5dc6c2c5488d5cc2b18719413 title-tokens.json -074cf8514b56b862e93a6f8794e6f5064e08dda7675e2a145ab4e2a955187905 tokens.css +1ac2b30fb03e204ab940f213714c1315f6b50224aff557b59b55230679bc6ffb src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +13526fae90cfa38063184c9d4374798387351d5e51fb8ac94f9942c898bc2163 title-tokens.json +59377d433fe2c020e72ce166780238ee6c70d2d44c86b5f167106f7b8b491394 tokens.css 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json