Preserve title data and focus across updates
This commit is contained in:
parent
0c94e8f910
commit
6d7df0fb19
31 changed files with 747 additions and 147 deletions
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import androidx.compose.ui.test.onRoot
|
|||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import org.json.JSONObject
|
||||
import org.junit.Before
|
||||
import org.junit.After
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
|
@ -43,14 +44,17 @@ import java.util.Locale
|
|||
class TitleCaptureTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var fixture: TitleFixture
|
||||
private var previousLocale = Locale.getDefault()
|
||||
|
||||
@Before fun fullscreen() {
|
||||
previousLocale = Locale.getDefault()
|
||||
Locale.setDefault(Locale.US)
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||
}
|
||||
@After fun restoreLocale() { Locale.setDefault(previousLocale) }
|
||||
|
||||
private fun capture(number: Int, title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }, configure: (TitleState, ShellState) -> Unit = { _, _ -> }) {
|
||||
val data = transform(fixture.title(title, person))
|
||||
|
|
@ -75,7 +79,9 @@ class TitleCaptureTest {
|
|||
if (data.item.backdrop == null || data.item.isPosterBackdrop) data.item.poster?.let { add(Triple(it, 420, 630)) }
|
||||
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
||||
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
||||
data.episodes.forEach { item -> (item.backdrop ?: item.poster)?.let { add(Triple(it, 416, 234)) } }
|
||||
data.episodes.forEach { item ->
|
||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 416, 234)) }
|
||||
}
|
||||
}
|
||||
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
||||
val bitmap = rule.activity.assets.open(path.removePrefix("file:///android_asset/")).use { BitmapFactory.decodeStream(it)!! }
|
||||
|
|
@ -89,7 +95,7 @@ class TitleCaptureTest {
|
|||
private fun save(number: Int) {
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() }
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "st-${number.toString().padStart(3, '0')}.png").absolutePath)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import org.jellyfin.sdk.model.api.MediaStream
|
|||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
|
|
@ -71,7 +70,7 @@ class TitleReadFacadeTest {
|
|||
every { repository.currentUser } returns null
|
||||
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
|
||||
val context = mockk<Context> { every { filesDir } returns directory.root }
|
||||
facade = TitleReadFacade(api, mockk<ImageUrlService>(), playback, repository, mockk<FavoriteWatchManager>(), context)
|
||||
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
|
||||
}
|
||||
|
||||
private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE) = BaseItemDto(
|
||||
|
|
@ -89,7 +88,7 @@ class TitleReadFacadeTest {
|
|||
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
|
||||
val saved = facade.readSaved(firstId, preferences)!!
|
||||
val screen = TitleState(saved, ShellPerson("justin", "Justin", "J", false)) {}
|
||||
assertEquals("mkv".uppercase(), saved.versions.single().container)
|
||||
assertEquals("MKV", saved.versions.single().container)
|
||||
assertEquals(3, screen.audio?.index)
|
||||
assertEquals(listOf(8), saved.versions.single().subtitles.map { it.index })
|
||||
assertEquals(TitleConnection.SignedOut, saved.connection)
|
||||
|
|
@ -109,6 +108,19 @@ class TitleReadFacadeTest {
|
|||
assertEquals("A real file", facade.readSaved(firstId, preferences)?.item?.name)
|
||||
}
|
||||
|
||||
@Test fun personBiographyAndContinuousCreditsRemainReadableFromDiskWithoutAService() = runTest {
|
||||
val actor = BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor", overview = "Actual biography")
|
||||
coEvery { library.getItem(firstId) } returns successResponse(actor)
|
||||
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(file(secondId)))
|
||||
facade.readPerson(firstId, 0)
|
||||
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
|
||||
coEvery { items.getItems(any<GetItemsRequest>()) } throws java.io.IOException("offline")
|
||||
val saved = facade.readPersonSaved(firstId)!!
|
||||
assertEquals("Actual biography", saved.person.biography)
|
||||
assertEquals(secondId.toString(), saved.credits.single().id)
|
||||
assertEquals("A real file", saved.credits.single().name)
|
||||
}
|
||||
|
||||
@Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest {
|
||||
val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9)
|
||||
val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1)
|
||||
|
|
@ -124,5 +136,6 @@ class TitleReadFacadeTest {
|
|||
assertEquals(firstId.toString(), data.watchTarget.id)
|
||||
assertEquals(listOf(secondId.toString()), data.episodes.map { it.id })
|
||||
assertEquals(listOf(2), data.seasonNumbers)
|
||||
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
||||
/** The rollout switch changes only intercepted screens; unrelated destinations always pass through. */
|
||||
fun resolveUpstreamDestination(destination: Destination, wolfskinEnabled: Boolean, libraries: Map<String, Destination>): Destination? =
|
||||
when (destination) {
|
||||
is Destination.Home, is Destination.MediaItem -> if (wolfskinEnabled) null else destination
|
||||
is Destination.Home -> if (wolfskinEnabled) null else destination
|
||||
is Destination.MediaItem -> if (wolfskinEnabled && destination.type in TITLE_KINDS) null else destination
|
||||
is Destination.Wolfskin -> if (!wolfskinEnabled && destination.route in LIBRARY_ROUTES) libraries[destination.route] else null
|
||||
else -> destination
|
||||
}
|
||||
|
||||
private val LIBRARY_ROUTES = setOf("movies", "shows", "anime")
|
||||
private val TITLE_KINDS = setOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE, BaseItemKind.PERSON)
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import org.justinvisser.wolfskin.design.PersonState
|
||||
import org.justinvisser.wolfskin.design.PersonSurface
|
||||
import org.justinvisser.wolfskin.design.ShellPerson
|
||||
|
|
@ -47,6 +49,7 @@ fun personRoute(id: String): String = "person/${Uri.encode(id)}"
|
|||
class WolfskinTitleViewModel @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val facade: TitleReadFacade,
|
||||
private val fixtureProvider: TitleFixtureProvider,
|
||||
private val navigation: NavigationManager,
|
||||
) : ViewModel() {
|
||||
var title by mutableStateOf<TitleState?>(null)
|
||||
|
|
@ -59,24 +62,32 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
private var fixture: TitleFixture? = null
|
||||
private var personReadPending = false
|
||||
private var personReadFinished = false
|
||||
private var loadJob: Job? = null
|
||||
private val saveMutex = Mutex()
|
||||
private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) }
|
||||
|
||||
/** Fixture reads are exclusively debug assets; real ids always use the authenticated Jellyfin facade. */
|
||||
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences) {
|
||||
if (route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
||||
route = destination
|
||||
loadJob?.cancel()
|
||||
person = null
|
||||
fixture = null
|
||||
personReadPending = false
|
||||
personReadFinished = false
|
||||
viewer = selected
|
||||
preferences = userPreferences
|
||||
val id = Uri.decode(destination.substringAfter('/'))
|
||||
title = TitleState(TitlePageData(TitleItem(id, "", "movie"), status = TitleReadStatus.Loading), viewer, ::handle)
|
||||
viewModelScope.launch {
|
||||
title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = TitleReadStatus.Loading), viewer, ::handle)
|
||||
loadJob = viewModelScope.launch {
|
||||
if (BuildConfig.DEBUG && id.toUuidOrNull() == null) {
|
||||
fixture = withContext(Dispatchers.IO) { TitleFixture(context.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) }
|
||||
val source = fixtureProvider.read()
|
||||
fixture = source
|
||||
if (destination.startsWith("person/")) {
|
||||
val (actor, credits) = fixture!!.person(id, viewer.key)
|
||||
val (actor, credits) = withContext(Dispatchers.IO) { source.person(id, selected.key) }
|
||||
person = PersonState(actor, credits, ::handle)
|
||||
} else {
|
||||
val data = fixture!!.title(id, viewer.key)
|
||||
val data = withContext(Dispatchers.IO) { source.title(id, selected.key) }
|
||||
val key = "${viewer.key}:${data.item.id}"
|
||||
val watchKey = "${viewer.key}:${data.watchTarget.id}"
|
||||
title = TitleState(data.copy(
|
||||
|
|
@ -93,50 +104,79 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
|
||||
private suspend fun readTitle(id: String) {
|
||||
val uuid = id.toUuidOrNull() ?: return
|
||||
facade.readSaved(uuid, preferences)?.let { title?.update(it) }
|
||||
val current = title ?: return
|
||||
try {
|
||||
val data = facade.read(uuid, title?.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences)
|
||||
title?.update(data)
|
||||
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(uuid, preferences)?.let { current.update(it) }
|
||||
val data = facade.read(uuid, current.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences)
|
||||
current.update(data)
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
title?.let { current ->
|
||||
if (current.data.status == TitleReadStatus.Ready) {
|
||||
current.toast = "Still can’t reach it. The last saved view stays on screen."
|
||||
} else current.update(current.data.copy(status = TitleReadStatus.Unavailable))
|
||||
}
|
||||
if (current.data.status == TitleReadStatus.Ready) {
|
||||
if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason()
|
||||
current.toast = "Still can’t reach it. The last saved view stays on screen."
|
||||
} else current.update(current.data.copy(status = TitleReadStatus.Unavailable))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readPerson(id: String) {
|
||||
if (personReadPending || personReadFinished) return
|
||||
val uuid = id.toUuidOrNull() ?: return
|
||||
val owner = viewer.key
|
||||
personReadPending = true
|
||||
try {
|
||||
val (actor, credits) = facade.readPerson(uuid, person?.credits?.size ?: 0)
|
||||
if (person == null) facade.readPersonSaved(uuid)?.let { saved ->
|
||||
if (owner == viewer.key) person = newPerson(saved.person, saved.credits).apply { savedAt = saved.savedAt }
|
||||
}
|
||||
val start = if (person?.savedAt != null) 0 else person?.credits?.size ?: 0
|
||||
val result = facade.readPerson(uuid, start)
|
||||
if (owner != viewer.key) return
|
||||
val current = person
|
||||
if (current == null) person = PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(id) } }
|
||||
else current.credits = (current.credits + credits).distinctBy { it.id }
|
||||
personReadFinished = credits.size < 200
|
||||
val credits = if (start == 0) result.credits else (current?.credits.orEmpty() + result.credits).distinctBy { it.id }
|
||||
if (current == null) person = newPerson(result.person, credits) else current.update(result.person, credits)
|
||||
personReadFinished = result.credits.size < 200
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable))
|
||||
} finally { personReadPending = false }
|
||||
if (owner == viewer.key) {
|
||||
person?.toast = "Still can’t reach it. The last saved view stays on screen."
|
||||
if (person == null) title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable))
|
||||
}
|
||||
} finally { if (owner == viewer.key) personReadPending = false }
|
||||
}
|
||||
|
||||
private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List<TitleItem>) =
|
||||
PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } }
|
||||
|
||||
private fun handle(action: TitleAction) {
|
||||
when (action) {
|
||||
TitleAction.Pop -> navigation.goBack()
|
||||
is TitleAction.Person -> navigation.navigateTo(Destination.Wolfskin(personRoute(action.person.id)))
|
||||
is TitleAction.OpenTitle -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.item.id)))
|
||||
is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id)))
|
||||
is TitleAction.OpenTitle -> navigation.navigateTo(action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
||||
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
||||
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
||||
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
||||
}) } ?: Destination.Wolfskin(titleRoute(action.item.id)))
|
||||
is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000)
|
||||
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0)
|
||||
else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer."
|
||||
is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
||||
is TitleAction.Season -> viewModelScope.launch {
|
||||
if (fixture == null) readTitle(Uri.decode(route.substringAfter('/')))
|
||||
is TitleAction.Season -> {
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
val current = title ?: return@launch
|
||||
current.showSeasonLoading()
|
||||
val source = fixture
|
||||
if (source == null) readTitle(Uri.decode(route.substringAfter('/')))
|
||||
else {
|
||||
val data = withContext(Dispatchers.IO) { source.title(Uri.decode(route.substringAfter('/')), viewer.key, action.number) }
|
||||
current.update(data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex,
|
||||
selectedSubtitle = current.subtitleIndex, watchTarget = current.data.watchTarget.copy(watched = current.watched), hidden = current.data.hidden))
|
||||
}
|
||||
}
|
||||
}
|
||||
TitleAction.Retry -> viewModelScope.launch {
|
||||
if (fixture == null) readTitle(Uri.decode(route.substringAfter('/')))
|
||||
if (fixture == null) {
|
||||
if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/'))) else readTitle(Uri.decode(route.substringAfter('/')))
|
||||
}
|
||||
else title?.toast = "Still can’t reach it. The last saved view stays on screen."
|
||||
}
|
||||
is TitleAction.Request, is TitleAction.Requests -> title?.toast = "Requests need this TV to be signed in to Conductarr."
|
||||
|
|
@ -158,6 +198,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun savePlayback(action: TitleAction) {
|
||||
val current = title ?: return
|
||||
if (fixture != null) {
|
||||
val edits = fixtureSelections.edit()
|
||||
when (action) {
|
||||
|
|
@ -170,14 +211,17 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
else -> Unit
|
||||
}
|
||||
edits.apply()
|
||||
current.confirmPlayback(action)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try { facade.apply(action) }
|
||||
catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
if (action is TitleAction.Watched) title?.restoreWatched(!action.watched)
|
||||
title?.toast = "Couldn’t save that change. Try again."
|
||||
saveMutex.withLock {
|
||||
try { facade.apply(action); current.confirmPlayback(action) }
|
||||
catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
current.rejectPlayback(action)
|
||||
current.toast = "Couldn’t save that change. Try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.justinvisser.wolfskin.design.ShellPerson
|
||||
|
|
@ -22,7 +29,7 @@ class TitleFixture(private val fixture: JSONObject) {
|
|||
private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy { key(it) }
|
||||
|
||||
/** Each viewer gets their own ownership, resume target and episode watch identity. */
|
||||
fun title(id: String, person: String): TitlePageData {
|
||||
fun title(id: String, person: String, season: Int? = null): TitlePageData {
|
||||
val profile = profiles.first { it.getString("key") == person }
|
||||
val library = listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() }
|
||||
val episodes = listOf("resume", "nextUp").flatMap { profile.getJSONArray(it).objects() }
|
||||
|
|
@ -34,6 +41,7 @@ class TitleFixture(private val fixture: JSONObject) {
|
|||
val play = selectedEpisode ?: episodes.firstOrNull { it.optString("seriesTitle") == container.getString("title") }
|
||||
?: own?.takeIf { it.optDouble("progress") > 0 }
|
||||
val target = play?.let { item(it, person) } ?: item.takeIf { it.held && it.kind == "movie" }
|
||||
val selectedSeason = season ?: play?.optInt("seasonNumber")?.takeIf { it > 0 } ?: 1
|
||||
val related = (pool + library).filter { key(it) != key(container) && overlap(it, container) > 0 }
|
||||
.sortedByDescending { overlap(it, container) }.distinctBy { key(it) }.take(16).map { item(it, person) }
|
||||
val castMembers = cast.optJSONArray(key(container))?.objects().orEmpty().map { member ->
|
||||
|
|
@ -42,8 +50,8 @@ class TitleFixture(private val fixture: JSONObject) {
|
|||
return TitlePageData(item, play?.let { item(it, person) }, selectedEpisode?.let { item(it, person) } ?: item,
|
||||
versions = target?.let { versions(it) }.orEmpty(),
|
||||
trailerUrl = if (item.held && key(container) in TRAILERS) "fixture:trailer" else null,
|
||||
cast = castMembers, related = related, episodes = episodes.filter { it.optString("seriesTitle") == container.getString("title") }.map { item(it, person) },
|
||||
connection = TitleConnection.SignedIn, pick = container.optString("source") == "materialized_personal_recommendation")
|
||||
cast = castMembers, related = related, episodes = episodes.filter { it.optString("seriesTitle") == container.getString("title") && it.optInt("seasonNumber") == selectedSeason }.map { item(it, person) },
|
||||
connection = TitleConnection.SignedIn, pick = container.optString("source") == "materialized_personal_recommendation", selectedSeason = selectedSeason)
|
||||
}
|
||||
|
||||
/** Only real household credits are retained; prototype filler is never promoted to filmography. */
|
||||
|
|
@ -121,4 +129,16 @@ class TitleFixture(private val fixture: JSONObject) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Every title entry shares one parsed, read-only debug fixture; release callers cannot load it. */
|
||||
@Singleton
|
||||
class TitleFixtureProvider @Inject constructor(@param:ApplicationContext private val context: Context) {
|
||||
private val fixture by lazy {
|
||||
TitleFixture(context.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||
}
|
||||
suspend fun read(): TitleFixture = withContext(Dispatchers.IO) {
|
||||
check(BuildConfig.DEBUG) { "Title fixtures are debug-only" }
|
||||
fixture
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONArray.objects(): List<JSONObject> = List(length()) { getJSONObject(it) }
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import java.util.Locale
|
|||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PersonRead(val person: TitlePerson, val credits: List<TitleItem>, val savedAt: String? = null)
|
||||
|
||||
/** Jellyfin SDK changes are confined to this boundary; title UI sees immutable screen data only. */
|
||||
class TitleReadFacade @Inject constructor(
|
||||
private val api: ApiClient,
|
||||
|
|
@ -59,23 +61,23 @@ class TitleReadFacade @Inject constructor(
|
|||
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items"))
|
||||
val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } }
|
||||
build(rows, numbers, saved.optInt("selectedSeason"), saved.getString("watch"), saved.optString("target").takeIf { it.isNotEmpty() }, preferences)
|
||||
.copy(savedAt = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified())),
|
||||
trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() })
|
||||
.copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() })
|
||||
} catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null }
|
||||
}
|
||||
|
||||
/** An episode opens its series with that exact episode as both play and watched target. */
|
||||
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
|
||||
val file = cacheFile(id)
|
||||
val opened = api.userLibraryApi.getItem(id).content
|
||||
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content else opened
|
||||
val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList()
|
||||
val selectedNumber = season ?: opened.parentIndexNumber ?: 1
|
||||
val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
|
||||
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
|
||||
}
|
||||
val selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber
|
||||
val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull()
|
||||
val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = fields)).content.items.orEmpty() }.orEmpty()
|
||||
val target = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
|
||||
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
|
||||
?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }
|
||||
}
|
||||
val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }
|
||||
val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id }
|
||||
val numbers = seasons.mapNotNull { it.indexNumber }
|
||||
val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences)
|
||||
|
|
@ -84,7 +86,6 @@ class TitleReadFacade @Inject constructor(
|
|||
val saved = JSONObject().put("items", json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows))
|
||||
.put("seasonNumbers", org.json.JSONArray(numbers)).put("selectedSeason", selected?.indexNumber).put("watch", opened.id.toString())
|
||||
.put("target", target?.id?.toString().orEmpty()).put("trailer", trailer.orEmpty())
|
||||
val file = cacheFile(id)
|
||||
file.parentFile?.mkdirs()
|
||||
try { file.writeText(saved.toString()) } catch (_: java.io.IOException) { /* Playback remains available when the disk cannot save a copy. */ }
|
||||
result.copy(trailerUrl = trailer)
|
||||
|
|
@ -110,15 +111,38 @@ class TitleReadFacade @Inject constructor(
|
|||
Unit
|
||||
}
|
||||
|
||||
/** A person keeps the same server/user-scoped last good copy as a title. */
|
||||
suspend fun readPersonSaved(id: UUID): PersonRead? = withContext(Dispatchers.IO) {
|
||||
val file = personCacheFile(id)
|
||||
if (!file.exists()) return@withContext null
|
||||
try {
|
||||
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), file.readText())
|
||||
personData(id, rows.first(), rows.drop(1)).copy(savedAt = savedTime(file))
|
||||
} catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null }
|
||||
}
|
||||
|
||||
/** Held performer credits are read continuously; a signed-out TV never falls back to Seerr. */
|
||||
suspend fun readPerson(id: UUID, start: Int): Pair<TitlePerson, List<TitleItem>> = withContext(Dispatchers.IO) {
|
||||
val person = api.userLibraryApi.getItem(id).content
|
||||
suspend fun readPerson(id: UUID, start: Int): PersonRead = withContext(Dispatchers.IO) {
|
||||
val file = personCacheFile(id)
|
||||
val previous = if (start > 0 && file.exists()) {
|
||||
try { json.decodeFromString(ListSerializer(BaseItemDto.serializer()), file.readText()) }
|
||||
catch (_: java.io.IOException) { emptyList() } catch (_: IllegalArgumentException) { emptyList() }
|
||||
} else emptyList()
|
||||
val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content
|
||||
val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200,
|
||||
sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty()
|
||||
TitlePerson(id.toString(), person.name.orEmpty(), portrait = images.getItemImageUrl(id, ImageType.PRIMARY, maxWidth = 240, maxHeight = 240), biography = person.overview.orEmpty()) to credits.map { mapItem(it) }
|
||||
val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id }
|
||||
file.parentFile?.mkdirs()
|
||||
try { file.writeText(json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) }
|
||||
catch (_: java.io.IOException) { /* The readable grid does not disappear when saving fails. */ }
|
||||
personData(id, person, credits)
|
||||
}
|
||||
|
||||
private fun personData(id: UUID, person: BaseItemDto, credits: List<BaseItemDto>): PersonRead =
|
||||
PersonRead(TitlePerson(id.toString(), person.name.orEmpty(), portrait = images.getItemImageUrl(id, ImageType.PRIMARY, maxWidth = 240, maxHeight = 240), biography = person.overview.orEmpty()),
|
||||
credits.map { credit -> mapItem(credit).copy(character = credit.people.orEmpty().firstOrNull { it.id == id }?.role) })
|
||||
|
||||
private suspend fun build(rows: List<BaseItemDto>, seasonNumbers: List<Int>, selectedSeason: Int?, watchId: String, targetId: String?, preferences: UserPreferences): TitlePageData {
|
||||
val item = rows.first()
|
||||
val target = rows.firstOrNull { it.id.toString() == targetId }
|
||||
|
|
@ -139,7 +163,7 @@ class TitleReadFacade @Inject constructor(
|
|||
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
|
||||
},
|
||||
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it) },
|
||||
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers)
|
||||
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
||||
}
|
||||
|
||||
private fun mapItem(item: BaseItemDto): TitleItem = TitleItem(
|
||||
|
|
@ -152,4 +176,6 @@ class TitleReadFacade @Inject constructor(
|
|||
)
|
||||
|
||||
private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json")
|
||||
private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json")
|
||||
private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ import androidx.compose.animation.core.tween
|
|||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -30,6 +35,7 @@ import androidx.tv.material3.Text
|
|||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
|
||||
|
||||
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
|
||||
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
|
||||
|
|
@ -50,7 +56,7 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
|
|||
|
||||
/** There is one blob, including when focus wraps around the nav avatar. */
|
||||
@Composable
|
||||
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
||||
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier, savedAt: String? = null) {
|
||||
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
|
||||
val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) }
|
||||
val index = if (state.navFocused) state.navIndex else state.currentTab
|
||||
|
|
@ -94,6 +100,15 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
|||
}
|
||||
}
|
||||
}
|
||||
if (savedAt != null) {
|
||||
Row(Modifier.align(Alignment.TopEnd)
|
||||
.offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2)
|
||||
.height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button))
|
||||
.padding(horizontal = V.side), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("wifi", C.warn, Modifier.size(V.icon))
|
||||
Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption)
|
||||
}
|
||||
}
|
||||
val avatar = boxes.last()
|
||||
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
|
||||
Modifier.offset(avatar.left, avatar.top)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -19,6 +20,8 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -36,8 +39,16 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
|||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** Filmography retains a real credit key and requests more credits before focus reaches the tail. */
|
||||
class PersonState(val person: TitlePerson, initialCredits: List<TitleItem>, private val onAction: (TitleAction) -> Unit, private val onReadMore: () -> Unit = {}) {
|
||||
class PersonState(initialPerson: TitlePerson, initialCredits: List<TitleItem>, private val onAction: (TitleAction) -> Unit, private val onReadMore: () -> Unit = {}) {
|
||||
var person by mutableStateOf(initialPerson)
|
||||
private set
|
||||
var credits by mutableStateOf(initialCredits)
|
||||
var savedAt by mutableStateOf<String?>(null)
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
fun update(actor: TitlePerson, titles: List<TitleItem>, saved: String? = null) {
|
||||
person = actor; credits = titles; savedAt = saved
|
||||
index = index.coerceAtMost((credits.size - 1).coerceAtLeast(0))
|
||||
}
|
||||
var index by mutableStateOf(0)
|
||||
private var armed: String? = null
|
||||
|
||||
|
|
@ -46,7 +57,11 @@ class PersonState(val person: TitlePerson, initialCredits: List<TitleItem>, priv
|
|||
|
||||
/** The first grid row exits upward into the existing shell nav. */
|
||||
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
|
||||
if (credits.isEmpty()) { if (down && key == ShellKey.Back) onAction(TitleAction.Pop); return true }
|
||||
if (credits.isEmpty()) {
|
||||
if (down && key == ShellKey.Back) onAction(TitleAction.Pop)
|
||||
else if (down && key == ShellKey.Up) shell.focusNav(shell.currentTab)
|
||||
return true
|
||||
}
|
||||
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
|
||||
if (key == ShellKey.Ok) {
|
||||
if (repeat > 0) return true
|
||||
|
|
@ -72,8 +87,11 @@ class PersonState(val person: TitlePerson, initialCredits: List<TitleItem>, priv
|
|||
@Composable
|
||||
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
|
||||
ShellSurface(shell, modifier, handlesContentKeys = false) { PersonPage(state, shell, it) }
|
||||
Box(modifier.fillMaxSize().background(C.bg)) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
|
||||
ShellSurface(shell, handlesContentKeys = false, savedAt = state.savedAt) { PersonPage(state, shell, it) }
|
||||
}
|
||||
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,13 +99,32 @@ fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Mo
|
|||
private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val density = LocalDensity.current
|
||||
val list = shell.view.listState
|
||||
var wheelPending by remember { mutableStateOf(false) }
|
||||
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
|
||||
LaunchedEffect(state.index / columns, shell.navFocused) {
|
||||
if (shell.navFocused || state.index < columns) list.animateScrollToItem(0)
|
||||
else list.animateScrollToItem(2 + state.index / columns, -with(density) { L.pinnedLine.roundToPx() })
|
||||
val target = if (shell.navFocused || state.index < columns) 0f
|
||||
else with(density) { (L.row_phead + L.row_gh + D.gridHeight * (state.index / columns) - L.pinnedLine).toPx() }
|
||||
val first = list.firstVisibleItemIndex
|
||||
val before = when (first) {
|
||||
0 -> WolfskinTokens.Shell.zero
|
||||
1 -> L.row_phead
|
||||
else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2)
|
||||
}
|
||||
val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset
|
||||
if (target != current) list.animateScrollBy(target - current, titleFrameScrollSpec)
|
||||
}
|
||||
Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" }
|
||||
.onPreviewKeyEvent { event -> readShellKey(event)?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false }.focusable()) {
|
||||
.titleWheel { wheelPending = true; shell.navFocused = false }
|
||||
.onPreviewKeyEvent { event ->
|
||||
val key = readShellKey(event)
|
||||
if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) {
|
||||
val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index >= 2 && it.key != "end-padding" && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() } }
|
||||
state.index = ((visible?.index?.minus(2) ?: 0) * columns).coerceAtMost((state.credits.size - 1).coerceAtLeast(0))
|
||||
wheelPending = false
|
||||
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
|
||||
}
|
||||
key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false
|
||||
}.focusable()) {
|
||||
LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
|
||||
item(key = "person-header", contentType = "header") {
|
||||
Box(Modifier.height(L.row_phead)) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ data class TitlePageData(
|
|||
val pick: Boolean = false,
|
||||
val savedAt: String? = null,
|
||||
val seasonNumbers: List<Int> = List(item.seasons) { it + 1 },
|
||||
val selectedSeason: Int? = null,
|
||||
val episodesStatus: TitleReadStatus = TitleReadStatus.Ready,
|
||||
)
|
||||
|
||||
/** Effects are handled by the app's authenticated facade and navigation layer. */
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -24,6 +26,12 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
|
@ -44,22 +52,28 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
|
||||
@Composable
|
||||
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
LaunchedEffect(state.toast) {
|
||||
if (state.toast != null) { delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null }
|
||||
}
|
||||
Box(modifier.fillMaxSize()) {
|
||||
val focusOwner = remember(shell) { { shell.navFocused = false } }
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
||||
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
||||
ShellSurface(shell, handlesContentKeys = false, savedAt = state.data.savedAt) { TitlePage(state, shell, it) }
|
||||
}
|
||||
if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell)
|
||||
if (!shell.pickerVisible) state.toast?.let { message ->
|
||||
Row(Modifier.align(Alignment.TopEnd).padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth)
|
||||
.background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("wifi", C.warn, Modifier.size(L.buttonIcon))
|
||||
Text(message, color = C.fg, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
||||
}
|
||||
}
|
||||
|
||||
/** Playback and person reads share one calm, timed notice above the persistent nav. */
|
||||
@Composable
|
||||
internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDismiss: () -> Unit) {
|
||||
LaunchedEffect(message) {
|
||||
if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() }
|
||||
}
|
||||
if (message != null) {
|
||||
Row(modifier.padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth)
|
||||
.background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("alert", C.warn, Modifier.size(L.buttonIcon))
|
||||
Text(message, color = C.fg, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +83,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
|||
fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val density = LocalDensity.current
|
||||
val list = shell.view.listState
|
||||
var wheelPending by remember { mutableStateOf(false) }
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
|
||||
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
||||
|
|
@ -78,31 +93,52 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
add("header")
|
||||
if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) {
|
||||
add("seasons")
|
||||
if (state.data.episodes.isNotEmpty()) add("episodes")
|
||||
if (state.data.episodesStatus == TitleReadStatus.Loading || state.data.episodes.isNotEmpty()) add("episodes")
|
||||
}
|
||||
if (state.data.castStatus == TitleReadStatus.Loading || state.data.cast.isNotEmpty()) add("cast")
|
||||
if (state.data.related.isNotEmpty()) add("related")
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.row, shell.navFocused, state.data.status) {
|
||||
val rowOffsets = remember(displayedRows, density) {
|
||||
FloatArray(displayedRows.size + 1).apply {
|
||||
displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row).toPx() } }
|
||||
}
|
||||
}
|
||||
val position by remember(list, rowOffsets) { derivedStateOf {
|
||||
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
|
||||
} }
|
||||
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows) {
|
||||
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
|
||||
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
|
||||
val y = displayedRows.take(index).fold(WolfskinTokens.Shell.zero) { total, row -> total + titleRowHeight(row) }
|
||||
val height = titleRowHeight(state.row)
|
||||
if (index == 0 || (y < L.windowAhead && y + height <= WolfskinTokens.Shell.stageHeight - L.safeTopPicker)) list.animateScrollToItem(0)
|
||||
else list.animateScrollToItem(index, -with(density) { L.pinnedLine.roundToPx() })
|
||||
val y = rowOffsets[index]
|
||||
val height = with(density) { titleRowHeight(state.row).toPx() }
|
||||
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
|
||||
else y - with(density) { L.pinnedLine.toPx() }
|
||||
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
|
||||
}
|
||||
val dim by remember { derivedStateOf {
|
||||
val before = displayedRows.take(list.firstVisibleItemIndex).sumOf { with(density) { titleRowHeight(it).toPx().toDouble() } }
|
||||
((before + list.firstVisibleItemScrollOffset) / D.dimScrollPx).toFloat().coerceAtMost(D.dimMaximum)
|
||||
} }
|
||||
val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
|
||||
val heroVisible by remember(rowOffsets, list, density) { derivedStateOf { position < with(density) { WolfskinTokens.Shell.stageHeight.toPx() } } }
|
||||
val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } }
|
||||
val nextArtRow = remember(displayedRows, lastVisible) { if (lastVisible < 0) null else displayedRows.drop(lastVisible + 1).firstOrNull { it == "cast" || it == "episodes" || it == "related" } }
|
||||
TitleRowPrefetch(state.data, nextArtRow)
|
||||
Box(modifier.focusRequester(focus).testTag("title-page").semantics { stateDescription = "${state.data.item.id}:${state.row}:${state.column}:${state.sheet}" }
|
||||
.titleWheel { if (state.data.status == TitleReadStatus.Ready) { wheelPending = true; shell.navFocused = false } }
|
||||
.onPreviewKeyEvent { event ->
|
||||
val key = readShellKey(event)
|
||||
if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) {
|
||||
val visible = list.layoutInfo.visibleItemsInfo.firstOrNull {
|
||||
it.key in state.rows && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() }
|
||||
}?.key as? String ?: "header"
|
||||
state.focus(visible, state.columns[visible] ?: 0)
|
||||
wheelPending = false
|
||||
if (key != ShellKey.Ok) return@onPreviewKeyEvent true
|
||||
}
|
||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable()) {
|
||||
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().background(C.bg.copy(alpha = dim)))
|
||||
if (heroVisible) {
|
||||
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
|
||||
} else Box(Modifier.fillMaxSize().background(C.bg))
|
||||
if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state)
|
||||
else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
|
||||
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
|
||||
|
|
@ -140,6 +176,7 @@ private fun TitleSeasons(state: TitleState, active: Boolean) {
|
|||
val selected = state.season == number
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.chip)
|
||||
Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) }
|
||||
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape)
|
||||
.then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier)
|
||||
.clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
|
||||
|
|
@ -151,9 +188,17 @@ private fun TitleSeasons(state: TitleState, active: Boolean) {
|
|||
|
||||
@Composable
|
||||
private fun TitleReadError(state: TitleState) {
|
||||
Column(Modifier.offset(L.safeSide, L.navBand).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(D.overviewGap), verticalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
Text("This title can’t be read", color = C.warn, style = WolfskinTokens.Type.stateTitle)
|
||||
Text("Conductarr didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }) { state.click("header", index) } } }
|
||||
Column(Modifier.offset(L.safeSide, D.errorTop).width(L.stateWidth)
|
||||
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.padding(horizontal = D.errorSide, vertical = D.errorVertical)) {
|
||||
Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
TitleGlyph("alert", C.warn, Modifier.size(D.receiptIcon))
|
||||
Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = D.errorTitle)
|
||||
}
|
||||
Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.errorBodyBottom))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }, overArtwork = false) { state.click("header", index) } } }
|
||||
}
|
||||
}
|
||||
|
||||
internal val titleFrameScrollSpec = tween<Float>(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,13 +32,15 @@ fun formatTitleRating(rating: TitleRating, locale: Locale = Locale.getDefault())
|
|||
|
||||
/** A full facts-line set never shrinks or waits for focused-card dwell. */
|
||||
@Composable
|
||||
fun TitleRatings(ratings: List<TitleRating>, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.button) {
|
||||
fun TitleRatings(ratings: List<TitleRating>, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.button, plate: Boolean = false) {
|
||||
val density = LocalDensity.current
|
||||
val markHeight = with(density) { style.fontSize.toDp() }
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(D.ratingGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
val gap = if (plate) with(density) { (style.fontSize * D.ratingPlateEntryEm).toDp() } else D.ratingGap
|
||||
val markGap = if (plate) with(density) { (style.fontSize * D.ratingPlateMarkEm).toDp() } else D.ratingMarkGap
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
ratings.forEach { rating ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(D.ratingMarkGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (rating.episode) Text("Ep", color = C.muted, style = style)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (rating.episode) Text("Ep", color = C.muted, style = style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
|
||||
val resource = when (rating.mark) {
|
||||
"rotten" -> R.drawable.ic_rotten_tomatoes_rotten
|
||||
"upright" -> R.drawable.ic_rt_upright
|
||||
|
|
@ -47,8 +49,8 @@ fun TitleRatings(ratings: List<TitleRating>, modifier: Modifier = Modifier, styl
|
|||
"tmdb" -> R.drawable.ic_rating_tmdb
|
||||
else -> R.drawable.ic_rotten_tomatoes_fresh
|
||||
}
|
||||
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") 1.8f else 1f, markHeight),
|
||||
alpha = if (rating.mark == "tomato") .55f else 1f,
|
||||
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f, markHeight),
|
||||
alpha = if (rating.mark == "tomato") D.mutedTomatoOpacity else 1f,
|
||||
colorFilter = if (rating.mark == "tomato") ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
|
||||
Text(formatTitleRating(rating), color = C.fg, style = style.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold), maxLines = 1)
|
||||
}
|
||||
|
|
@ -68,16 +70,27 @@ fun TitleRatingPlate(ratings: List<TitleRating>, modifier: Modifier = Modifier)
|
|||
val fit = remember(ratings, width, density, Locale.getDefault()) {
|
||||
var size = WolfskinTokens.Type.ratingPlate.fontSize.value
|
||||
var count = ratings.size
|
||||
val numbers = ratings.map { formatTitleRating(it) }
|
||||
fun measured(): Float {
|
||||
val text = ratings.take(count).joinToString("") { (if (it.episode) "Ep " else "") + formatTitleRating(it) }
|
||||
val textWidth = measurer.measure(text, WolfskinTokens.Type.button.copy(fontSize = TextUnit(size, TextUnitType.Sp))).size.width
|
||||
val marks = ratings.take(count).sumOf { if (it.source == "imdb" || it.source == "tmdb") 1.8 else 1.0 }.toFloat()
|
||||
return textWidth + with(density) { TextUnit(size * marks, TextUnitType.Sp).toPx() + D.ratingMarkGap.toPx() * count + D.ratingGap.toPx() * (count - 1) }
|
||||
val fontSize = TextUnit(size, TextUnitType.Sp)
|
||||
val style = WolfskinTokens.Type.button.copy(fontSize = fontSize, fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold)
|
||||
val markGap = with(density) { (fontSize * D.ratingPlateMarkEm).toPx() }
|
||||
val entryGap = with(density) { (fontSize * D.ratingPlateEntryEm).toPx() }
|
||||
var total = 0f
|
||||
for (index in 0 until count) {
|
||||
val rating = ratings[index]
|
||||
total += measurer.measure(numbers[index], style, maxLines = 1).size.width
|
||||
total += with(density) { fontSize.toPx() } * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f
|
||||
total += markGap
|
||||
if (rating.episode) total += measurer.measure("Ep", style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), maxLines = 1).size.width + markGap
|
||||
if (index > 0) total += entryGap
|
||||
}
|
||||
return total
|
||||
}
|
||||
while (size > WolfskinTokens.Type.caption.fontSize.value && measured() > width) size = (size - .25f).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value)
|
||||
while (size > WolfskinTokens.Type.caption.fontSize.value && measured() > width) size = (size - D.ratingPlateStepSp).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value)
|
||||
while (count > 1 && measured() > width) count--
|
||||
count to size
|
||||
}
|
||||
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.button.copy(fontSize = TextUnit(fit.second, TextUnitType.Sp)))
|
||||
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.button.copy(fontSize = TextUnit(fit.second, TextUnitType.Sp)), plate = true)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -15,6 +23,9 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
|
|
@ -39,17 +50,19 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
fun TitleCast(state: TitleState, active: Boolean) {
|
||||
val track = rememberLazyListState()
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
LaunchedEffect(state.column, active) { if (active) track.animateScrollToItem((state.column - 5).coerceAtLeast(0)) }
|
||||
val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() }
|
||||
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) }
|
||||
Column(Modifier.height(D.castHeight)) {
|
||||
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
|
||||
if (state.data.castStatus == TitleReadStatus.Loading) {
|
||||
items(7, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||
Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
|
||||
}
|
||||
} else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person ->
|
||||
val focused = active && state.column == index
|
||||
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
|
||||
.onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) }
|
||||
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
|
||||
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape))
|
||||
Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
|
|
@ -70,13 +83,20 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
|||
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
|
||||
val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
|
||||
val track = rememberLazyListState()
|
||||
val visible = if (episodes) 4 else 6
|
||||
LaunchedEffect(state.column, active) { if (active) track.animateScrollToItem((state.column - (visible - 2)).coerceAtLeast(0)) }
|
||||
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
|
||||
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
|
||||
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) }
|
||||
Column(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
|
||||
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
|
||||
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||
itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
|
||||
TitlePoster(item, active && state.column == index, Modifier.width(width), episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
|
||||
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
|
||||
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
|
||||
Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
}
|
||||
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
|
||||
TitlePoster(item, active && state.column == index, Modifier.width(width)
|
||||
.onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) },
|
||||
episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,24 +112,46 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
|||
Box(Modifier.size(width, height).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
|
||||
TitleArtwork(if (tile) item.backdrop ?: item.poster else item.poster, (width.value * 2).toInt(), (height.value * 2).toInt(), Modifier.fillMaxSize(), !tile)
|
||||
if (item.poster == null && item.backdrop == null) Text(item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 4, modifier = Modifier.padding(D.lineGap))
|
||||
TitleCardArtwork(item, tile)
|
||||
if (tile) {
|
||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg))).padding(D.lineGap)) {
|
||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
||||
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
||||
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(if (item.kind == "episode") "${item.episodeLabel} · ${item.name}" else "${if (item.kind == "movie") "Movie" else "Series"} · ${item.year ?: ""}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (item.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) {
|
||||
val progress = if (item.runtimeSeconds > 0) item.resumeSeconds.toFloat() / item.runtimeSeconds else 0f
|
||||
if (progress > 0f && progress < 1f) {
|
||||
Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = if (tile) D.tileProgressBottom else D.cardProgressBottom)
|
||||
.fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
|
||||
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
|
||||
}
|
||||
}
|
||||
if (item.held && !tile) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) {
|
||||
TitleGlyph("check", C.onFocus, Modifier.size(L.buttonIcon))
|
||||
}
|
||||
if (focused && ratingsVisible) TitleRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
||||
AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) {
|
||||
TitleRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
if (!tile) {
|
||||
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) {
|
||||
Caption(item.name, listOfNotNull(item.character?.let { "as $it" }, item.year?.toString(), if (item.kind == "movie") item.runtime else if (item.seasons > 0) "${item.seasons} seasons" else "Series", if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }).joinToString(" · "), focused,
|
||||
Caption(item.name, listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), if (item.kind == "movie") item.runtime.takeIf { item.runtimeSeconds > 0 } ?: "Movie" else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }).joinToString(" · "), focused,
|
||||
Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep a full lookahead card, but do not recenter a row when reversing direction. */
|
||||
private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float) {
|
||||
val first = firstVisibleItemIndex
|
||||
val next = when {
|
||||
column < first -> column
|
||||
column >= first + slots - 1 -> (column - slots + 2).coerceAtLeast(0)
|
||||
else -> first
|
||||
}
|
||||
if (next != first) animateScrollBy((next - first) * pitch - firstVisibleItemScrollOffset, titleTrackScrollSpec)
|
||||
}
|
||||
|
||||
private val titleTrackScrollSpec = tween<Float>(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))
|
||||
|
|
|
|||
|
|
@ -17,10 +17,14 @@ class TitleState(
|
|||
var row by mutableStateOf("header")
|
||||
var column by mutableStateOf(0)
|
||||
val columns = mutableStateMapOf<String, Int>()
|
||||
private val centers = HashMap<String, MutableMap<Int, Float>>()
|
||||
fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x }
|
||||
fun showSeasonLoading() { if (data.episodesStatus != TitleReadStatus.Loading) data = data.copy(episodesStatus = TitleReadStatus.Loading) }
|
||||
fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) }
|
||||
var sheet by mutableStateOf<TitleSheet?>(null)
|
||||
private set
|
||||
var sheetIndex by mutableStateOf(0)
|
||||
var season by mutableStateOf(initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
|
||||
var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
|
||||
var versionId by mutableStateOf(initial.selectedVersion)
|
||||
var audioIndex by mutableStateOf(initial.selectedAudio)
|
||||
var subtitleIndex by mutableStateOf(initial.selectedSubtitle)
|
||||
|
|
@ -37,7 +41,7 @@ class TitleState(
|
|||
if (data.status != TitleReadStatus.Ready) return@buildList
|
||||
if (data.item.held && data.item.kind == "tv" && data.item.seasons > 0) {
|
||||
add("seasons")
|
||||
if (data.episodes.isNotEmpty()) add("episodes")
|
||||
if (data.episodesStatus == TitleReadStatus.Ready && data.episodes.isNotEmpty()) add("episodes")
|
||||
}
|
||||
if (data.castStatus == TitleReadStatus.Ready && data.cast.isNotEmpty()) add("cast")
|
||||
if (data.related.isNotEmpty()) add("related")
|
||||
|
|
@ -47,12 +51,16 @@ class TitleState(
|
|||
/** Refresh keeps the selected row and file when they still exist. */
|
||||
fun update(value: TitlePageData) {
|
||||
val wasLoading = data.status == TitleReadStatus.Loading
|
||||
val pendingWatched = watched != data.watchTarget.watched
|
||||
val pendingVersion = versionId != data.selectedVersion
|
||||
val pendingAudio = audioIndex != data.selectedAudio
|
||||
val pendingSubtitle = subtitleIndex != data.selectedSubtitle
|
||||
data = value
|
||||
if (wasLoading) season = value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
|
||||
watched = value.watchTarget.watched
|
||||
versionId = value.selectedVersion
|
||||
audioIndex = value.selectedAudio
|
||||
subtitleIndex = value.selectedSubtitle
|
||||
if (wasLoading || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
|
||||
if (!pendingWatched) watched = value.watchTarget.watched
|
||||
if (!pendingVersion) versionId = value.selectedVersion
|
||||
if (!pendingAudio) audioIndex = value.selectedAudio
|
||||
if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
|
||||
if (row !in rows) { row = "header"; column = 0 }
|
||||
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
|
||||
}
|
||||
|
|
@ -71,8 +79,29 @@ class TitleState(
|
|||
}
|
||||
|
||||
|
||||
/** Film and episode toggles are optimistic; the facade restores this value on failure. */
|
||||
fun restoreWatched(value: Boolean) { watched = value }
|
||||
/** Keep the last confirmed selections separate from immediate, optimistic UI feedback. */
|
||||
fun confirmPlayback(action: TitleAction) {
|
||||
data = when (action) {
|
||||
is TitleAction.Version -> data.copy(selectedVersion = action.version.id, selectedAudio = null, selectedSubtitle = -1)
|
||||
is TitleAction.Track -> data.copy(selectedVersion = action.version.id, selectedAudio = action.audio, selectedSubtitle = action.subtitle)
|
||||
is TitleAction.Watched -> data.copy(watchTarget = data.watchTarget.copy(watched = action.watched))
|
||||
else -> data
|
||||
}
|
||||
}
|
||||
|
||||
/** A failed older write cannot undo a newer selection still waiting to save. */
|
||||
fun rejectPlayback(action: TitleAction) {
|
||||
when (action) {
|
||||
is TitleAction.Version -> if (versionId == action.version.id) {
|
||||
versionId = data.selectedVersion; audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle
|
||||
}
|
||||
is TitleAction.Track -> if (audioIndex == action.audio && subtitleIndex == action.subtitle) {
|
||||
audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle
|
||||
}
|
||||
is TitleAction.Watched -> if (watched == action.watched) watched = data.watchTarget.watched
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** Key-up can only activate the same card that armed the first key-down. */
|
||||
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
|
||||
|
|
@ -86,7 +115,7 @@ class TitleState(
|
|||
}
|
||||
if (repeat > 0 || okHeld) return true
|
||||
okHeld = true
|
||||
if (sheet == null && row in listOf("cast", "related", "episodes")) {
|
||||
if (sheet == null && (row == "cast" || row == "related" || row == "episodes")) {
|
||||
armedCard = "$row:$column"
|
||||
return true
|
||||
}
|
||||
|
|
@ -115,7 +144,9 @@ class TitleState(
|
|||
private fun moveRow(delta: Int) {
|
||||
val index = (rows.indexOf(row) + delta).coerceIn(0, rows.lastIndex)
|
||||
val next = rows[index]
|
||||
focus(next, (columns[next] ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
|
||||
val x = centers[row]?.get(column)
|
||||
val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null
|
||||
focus(next, (columns[next] ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
private fun rowSize(id: String): Int = when (id) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,44 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.Disposable
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.request.crossfade
|
||||
import coil3.ImageLoader
|
||||
|
||||
/** Owned screens share one bounded loader; standalone fixtures can use Coil's local asset loader. */
|
||||
val LocalWolfskinImageLoader = compositionLocalOf<ImageLoader?> { null }
|
||||
|
||||
/** Visible and prefetched images use exactly the same size and decode policy. */
|
||||
internal fun titleImageRequest(context: Context, path: String, width: Int, height: Int, poster: Boolean): ImageRequest =
|
||||
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
|
||||
|
||||
/** Read the next hidden art row into the bounded loader, cancelling work when the window changes. */
|
||||
@Composable
|
||||
internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
|
||||
val loader = LocalWolfskinImageLoader.current ?: return
|
||||
val context = LocalContext.current
|
||||
if (row == null || data.status != TitleReadStatus.Ready) return
|
||||
DisposableEffect(loader, context, row, data.cast, data.episodes, data.related) {
|
||||
val requests = ArrayList<Disposable>()
|
||||
fun queue(path: String?, width: Int, height: Int, poster: Boolean = false) {
|
||||
if (path != null) requests.add(loader.enqueue(titleImageRequest(context, path, width, height, poster)))
|
||||
}
|
||||
when (row) {
|
||||
"cast" -> for (index in 0 until minOf(data.cast.size, WolfskinTokens.Card.personSlots))
|
||||
queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight)
|
||||
"episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) {
|
||||
val item = data.episodes[index]
|
||||
queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight)
|
||||
}
|
||||
"related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots))
|
||||
queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true)
|
||||
}
|
||||
onDispose { requests.forEach { it.dispose() } }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
wolfskin-design/src/main/res/drawable/title_alert.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_alert.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M12,3 L2,20 H22 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M12,10 V14.5 M12,17 V17.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
|
||||
</vector>
|
||||
4
wolfskin-design/src/main/res/drawable/title_film.xml
Normal file
4
wolfskin-design/src/main/res/drawable/title_film.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5.5,4 H18.5 A2.5,2.5 0,0 1,21 6.5 V17.5 A2.5,2.5 0,0 1,18.5 20 H5.5 A2.5,2.5 0,0 1,3 17.5 V6.5 A2.5,2.5 0,0 1,5.5 4 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" />
|
||||
<path android:pathData="M7,4 V20 M17,4 V20 M3,9 H7 M3,15 H7 M17,9 H21 M17,15 H21" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" />
|
||||
</vector>
|
||||
|
|
@ -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"}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue