Merge Libraries and reuse the shared catalogue surface

This commit is contained in:
justinzeus 2026-11-05 17:55:49 +01:00
commit c1559e2513
634 changed files with 2167 additions and 276 deletions

View file

@ -3,36 +3,50 @@ package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
/** Accent facts identify availability without colouring the intervening separators. */
data class CaptionFact(val text: String, val accent: Boolean = false)
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
/** Captions fade without moving the reserved band under the focused poster. */
@Composable
fun Caption(title: String, facts: List<CaptionFact>, visible: Boolean, modifier: Modifier = Modifier) {
val alpha = remember { Animatable(0f) }
LaunchedEffect(visible, title) {
alpha.snapTo(0f)
if (visible) alpha.animateTo(1f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false) {
val alpha = remember(title, facts) { Animatable(0f) }
LaunchedEffect(title, facts, visible) {
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
}
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row(Modifier.clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Discover.captionFactsGap)) {
facts.forEachIndexed { index, fact ->
if (index > 0) Text("·", color = WolfskinTokens.Color.muted.copy(alpha = WolfskinTokens.Color.muted.alpha * WolfskinTokens.Discover.captionSeparatorOpacity), style = WolfskinTokens.Type.caption, maxLines = 1)
Text(fact.text, color = if (fact.accent) WolfskinTokens.Color.accentText else WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1)
val styledFacts = remember(facts, accentLastFact, accentFirstFact) {
buildAnnotatedString {
val separator = " · "
append(facts)
if (accentFirstFact) {
val end = facts.indexOf(separator).takeIf { it >= 0 } ?: facts.length
addStyle(SpanStyle(color = WolfskinTokens.Color.accentText), 0, end)
}
if (accentLastFact) {
val suffix = facts.lastIndexOf(separator).takeIf { it >= 0 }?.plus(separator.length) ?: facts.length
if (suffix < facts.length) addStyle(SpanStyle(color = WolfskinTokens.Color.accentText), suffix, facts.length)
}
var start = facts.indexOf(separator)
if (start >= 0) {
val muted = WolfskinTokens.Color.muted
val separatorStyle = SpanStyle(color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity))
val dotOffset = separator.indexOf('·')
while (start >= 0) {
addStyle(separatorStyle, start + dotOffset, start + dotOffset + 1)
start = facts.indexOf(separator, start + separator.length)
}
}
}
}
Text(styledFacts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1)
}
}

View file

@ -7,6 +7,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
@ -19,7 +23,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.compose.AsyncImage
@ -29,15 +34,19 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Native capture supplies local painters so screenshot tests never perform image HTTP reads. */
val LocalDiscoverPainter = compositionLocalOf<((String) -> Painter?)?> { null }
/** Native captures provide decoded local art; neither posters nor heroes perform image HTTP reads. */
val LocalDiscoverArt = compositionLocalOf<((String) -> ImageBitmap?)?> { null }
/** Decode requests use the actual art slot, with RGB565 only for upright posters. */
@Composable
fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, dim: Boolean = false) {
if (path == null) return
val painter = LocalDiscoverPainter.current?.invoke(path)
val filter = remember(dim) { if (dim) ColorFilter.colorMatrix(ColorMatrix().apply { setToScale(.55f, .55f, .55f, 1f) }) else null }
val bitmap = LocalDiscoverArt.current?.invoke(path)
val painter = remember(bitmap) { bitmap?.let(::BitmapPainter) }
val filter = remember(dim) { if (dim) ColorFilter.colorMatrix(ColorMatrix().apply {
setToSaturation(D.ambientSaturation)
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
}) else null }
val context = LocalContext.current
val request = remember(context, path, poster) {
ImageRequest.Builder(context).data(path).size(if (poster) 256 else 1280, if (poster) 384 else 720).allowRgb565(poster).build()
@ -45,6 +54,7 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
else AsyncImage(
model = request,
imageLoader = LibraryImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter,
)
}
@ -52,9 +62,12 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
/** Poster-only titles retain the one-sheet over a blurred wash rather than stretching the poster. */
@Composable
fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) {
Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(C.surface3)) {
Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).clipToBounds().background(C.surface3)) {
if (title.posterOnly) {
DiscoverArtwork(title.poster, Modifier.fillMaxSize().blur(L.rowHeadingBand), dim = true)
DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset)
.wrapContentSize(Alignment.TopStart, unbounded = true)
.size(WolfskinTokens.Shell.stageWidth + D.ambientInset * 2, WolfskinTokens.Shell.stageHeight + D.ambientInset * 2)
.graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }.blur(D.ambientBlur), dim = true)
DiscoverArtwork(title.poster, Modifier.offset(
WolfskinTokens.Shell.stageWidth - D.heroSheetRight - if (detail) L.heroSheetWidth else D.heroSheetWidth,
if (detail) L.navBand else D.heroSheetTop,

View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
/** Discover metadata projects into the same poster and grid contract as Libraries. */
internal fun DiscoverTitle.libraryCard(state: DiscoverState): LibraryTitle {
val receipts = state.requests[key].orEmpty()
val held = state.shell.person.key in heldBy
val availability = when {
held -> "In your library"
receipts.isNotEmpty() -> {
val recipients = receipts.joinToString(", ") { request ->
if (request.requester == state.shell.person.key) "you"
else state.shell.people.firstOrNull { it.key == request.requester }?.name ?: request.requester.orEmpty()
}
"Requested for $recipients · ${receipts.first().stage}"
}
heldBy.isNotEmpty() -> "In ${heldBy.joinToString(" and ") { key -> state.shell.people.firstOrNull { it.key == key }?.name ?: key }}’s library"
else -> ""
}
return LibraryTitle(id = key, title = title, type = type, year = year,
runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasonCount, poster = poster,
ratings = ratings.map { LibraryRating(it.source, it.score, it.label) }, availability = availability,
badge = when {
held -> "held"
receipts.isEmpty() -> null
receipts.all { it.stage == "Failed" } -> "failed"
else -> "requested"
})
}

View file

@ -1,86 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Catalogue See all shares poster anatomy with shelves and never introduces a page selector. */
@Composable
fun DiscoverCollection(state: DiscoverState, collection: DiscoverCollectionState, modifier: Modifier = Modifier) {
val list = state.shell.view.listState
val density = LocalDensity.current
val headerRows = if (collection.hasChips) 2 else 1
LaunchedEffect(collection.row, collection.chipsFocused, state.shell.navFocused) {
if (!state.shell.navFocused) {
if (collection.row == 0 || collection.chipsFocused) list.animateScrollToItem(0)
else list.animateScrollToItem(collection.row + headerRows, -with(density) { L.pinnedLine.roundToPx() })
}
}
LazyColumn(state = list, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine), modifier = modifier.fillMaxSize()) {
item(key = "collection-header", contentType = "header") {
Row(Modifier.fillMaxWidth().height(L.row_lhead).padding(start = L.safeSide, top = D.headerTop), horizontalArrangement = Arrangement.spacedBy(D.headerGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(state.shell.person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader)
Text(collection.lane.title, color = C.fg, style = WolfskinTokens.Type.screenTitle)
Text("${collection.items.size} titles", color = C.muted, style = WolfskinTokens.Type.nav)
}
}
if (collection.hasChips) item(key = "collection-chips", contentType = "chips") {
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = D.chipTop), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
listOf("All", "Movies", "Series").forEachIndexed { index, label ->
val focused = collection.chipsFocused && collection.chip == index && state.contentFocused
val selected = collection.filter == listOf("all", "movie", "tv")[index]
Box(Modifier.height(D.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip))
.border(if (!focused && selected) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && selected) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip))
.clickable { collection.chipsFocused = true; collection.chip = index; collection.key(ShellKey.Ok, true) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
Text(label, color = if (focused) C.onFocus else if (selected) C.accentText else C.fg, style = WolfskinTokens.Type.label)
}
}
}
}
items(collection.rowCount, key = { "grid-$it" }, contentType = { "poster" }) { row ->
val offset = row * WolfskinTokens.Behaviour.gridColumns.toInt()
Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) {
Row(Modifier.offset(x = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
if (offset >= collection.items.size) DiscoverTailCard(collection.lane, state.contentFocused && collection.row == row) { collection.focus(row, 0); state.refresh() }
else collection.items.subList(offset, minOf(offset + 6, collection.items.size)).forEachIndexed { column, item ->
DiscoverPoster(state, item, state.contentFocused && !collection.chipsFocused && collection.row == row && collection.column == column, false,
Modifier.clickable { collection.focus(row, column); state.openTitle(item) })
}
}
if (state.contentFocused && !collection.chipsFocused && collection.row == row) {
val item = collection.items.getOrNull(collection.index)
Caption(
item?.title ?: if (collection.lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
if (item != null) buildDiscoverCaptionFacts(item, state, false) else listOf(CaptionFact("The last read only repeated titles")),
true, Modifier.offset(L.safeSide + K.posterPitch * collection.column, K.posterH + L.captionOffset).width(D.captionWidth),
)
}
}
}
}
}

View file

@ -1,63 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/** See all retains a continuous six-column grid and its exact origin beneath the title stack. */
class DiscoverCollectionState(
val shell: ShellState,
val lane: DiscoverLane,
private val openTitle: (DiscoverTitle) -> Unit,
private val refresh: () -> Unit,
private val pop: () -> Unit,
) {
var filter by mutableStateOf("all")
var chipsFocused by mutableStateOf(false)
var chip by mutableStateOf(0)
private var armed: Int? = null
private val filteredItems = derivedStateOf { if (filter == "all") lane.fullTitles else lane.fullTitles.filter { it.type == filter } }
val items: List<DiscoverTitle> get() = filteredItems.value
val hasChips: Boolean = lane.fullTitles.any { it.type == "movie" } && lane.fullTitles.any { it.type == "tv" }
val row: Int get() = shell.view.row
val column: Int get() = shell.view.cards["grid-$row"] ?: 0
val index: Int get() = row * 6 + column
val hasTail: Boolean get() = lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)
val rowCount: Int get() = (items.size + 5) / 6 + if (hasTail) 1 else 0
fun focus(row: Int, column: Int) { shell.navFocused = false; chipsFocused = false; shell.view.row = row; shell.view.cards["grid-$row"] = column }
fun key(key: ShellKey, down: Boolean): Boolean {
if (key == ShellKey.Ok && !chipsFocused) {
if (down) armed = index else {
if (armed == index) { val item = items.getOrNull(index); if (item != null) openTitle(item) else if (hasTail) refresh() }
armed = null
}
return true
}
if (!down) return true
armed = null
if (key == ShellKey.Back) { pop(); return true }
if (chipsFocused) {
when (key) {
ShellKey.Up -> shell.focusNav(shell.currentTab)
ShellKey.Down -> chipsFocused = false
ShellKey.Left -> chip = (chip - 1).coerceAtLeast(0)
ShellKey.Right -> chip = (chip + 1).coerceAtMost(2)
ShellKey.Ok -> { filter = listOf("all", "movie", "tv")[chip]; focus(0, 0); chipsFocused = true }
else -> Unit
}
} else when (key) {
ShellKey.Up -> if (row > 0) focus(row - 1, column.coerceAtMost(rowSize(row - 1) - 1)) else if (hasChips) chipsFocused = true else shell.focusNav(shell.currentTab)
ShellKey.Down -> if (row < rowCount - 1) focus(row + 1, column.coerceAtMost(rowSize(row + 1) - 1))
ShellKey.Left -> focus(row, (column - 1).coerceAtLeast(0))
ShellKey.Right -> focus(row, (column + 1).coerceAtMost(rowSize(row) - 1))
else -> Unit
}
return true
}
private fun rowSize(row: Int): Int = if (hasTail && row == rowCount - 1) 1 else (items.size - row * 6).coerceIn(1, 6)
}

View file

@ -23,6 +23,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.font.FontWeight
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
@ -42,7 +43,7 @@ fun DiscoverOverlay(state: DiscoverState) {
ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.")
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) }
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok, true) }
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) }
}
}
is DiscoverOverlayState.Restore -> {
@ -67,12 +68,18 @@ fun DiscoverOverlay(state: DiscoverState) {
@Composable
private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
Text(if (sheet.held) "Add for someone" else "Request", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = WolfskinTokens.Type.body)
if (state.isSignedIn) DiscoverRatings(sheet.title.ratings)
Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}",
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop), color = C.muted, style = WolfskinTokens.Type.body)
if (state.isSignedIn) DiscoverRatings(sheet.title.ratings, Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height + (D.sheetRatingTop + D.sheetRatingBottom).roundToPx()) {
placeable.placeRelative(0, D.sheetRatingTop.roundToPx())
}
})
when (sheet.phase) {
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
RequestPhase.LoadingRecipients -> {
Text("For", color = C.dim, style = WolfskinTokens.Type.label)
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = WolfskinTokens.Type.label)
repeat(3) {
Row(Modifier.fillMaxWidth().height(L.sheetRow).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)).padding(horizontal = D.sheetRowPadding),
horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
@ -88,7 +95,7 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
@Composable
private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
Text("For", color = C.dim, style = WolfskinTokens.Type.label)
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = WolfskinTokens.Type.label)
sheet.people.forEachIndexed { index, person ->
val focused = sheet.focus == index
val disabled = person.key in sheet.title.heldBy
@ -108,8 +115,9 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
}
if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = WolfskinTokens.Type.caption)
else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = WolfskinTokens.Type.caption)
Spacer(Modifier.height(D.sheetButtonTop))
DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus") { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok, true) }
Box(Modifier.padding(top = D.sheetButtonTop)) {
DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus") { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
}
}
@Composable
@ -136,7 +144,7 @@ private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = WolfskinTokens.Type.caption)
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) {
DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok, true) }
DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false) { state.overlay = null }
} else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true) { state.overlay = null }
}

View file

@ -39,13 +39,13 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@Composable
fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
val shell = state.shell
val input = remember(state) { state::key }
DisposableEffect(state) {
shell.contentInput = input
onDispose { if (shell.contentInput === input) { shell.contentInput = null; shell.contentOverlayVisible = false } }
val focus = remember(state) { object : ShellContentFocus by state {} }
DisposableEffect(focus) {
shell.contentFocus = focus
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } }
}
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
val focusedTitle = if (state.collection != null && state.title == null) state.collection?.let { it.items.getOrNull(it.index) } else state.focusedLane?.titles?.getOrNull(state.column)
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
LaunchedEffect(focusedTitle?.key, state.contentFocused) {
state.plateKey = null
if (state.contentFocused && focusedTitle != null) {
@ -54,7 +54,15 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
}
}
if (state.title != null) { DiscoverTitleDestination(state, modifier); return }
state.collection?.let { DiscoverCollection(state, it, modifier); return }
state.collection?.let {
LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false)
return
}
if (state.snapshot.status == DiscoverStatus.Loading) {
val loading = remember(state) { LibraryGridState("Discover", emptyList(), catalogue = true).apply { status = LibraryStatus.Loading } }
LibraryScreen(shell, loading, modifier, offlineArt = LocalDiscoverArt.current, registerFocus = false)
return
}
val density = LocalDensity.current
val list = shell.view.listState
LaunchedEffect(state.focusedRow, shell.navFocused) {
@ -89,7 +97,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
@Composable
private fun DiscoverHeader(person: ShellPerson, title: String, subtitle: String) {
Row(Modifier.fillMaxWidth().height(L.row_lhead).padding(start = L.safeSide, top = D.headerTop), horizontalArrangement = Arrangement.spacedBy(D.headerGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader)
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
Text(title, color = C.fg, style = WolfskinTokens.Type.screenTitle)
if (subtitle.isNotEmpty()) Text(subtitle, color = C.muted, style = WolfskinTokens.Type.nav)
}
@ -134,7 +142,7 @@ private fun DiscoverChips(state: DiscoverState) {
Box(Modifier.height(D.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.background(if (focused) C.fg else if (state.filter == value) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip))
.border(if (!focused && state.filter == value) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && state.filter == value) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip))
.clickable { state.focus(1, index); state.key(ShellKey.Ok, true) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
.clickable { state.focus(1, index); state.key(ShellKey.Ok) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = WolfskinTokens.Type.label)
}
}
@ -144,7 +152,10 @@ private fun DiscoverChips(state: DiscoverState) {
@Composable
private fun DiscoverStatePanel(state: DiscoverState) {
val signIn = state.snapshot.status == DiscoverStatus.NeedsSignIn
Column(Modifier.padding(start = L.safeSide, top = D.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(D.statePaddingX, D.statePaddingY)) {
Column(Modifier.padding(start = L.safeSide, top = D.stateTop).width(L.stateWidth)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(WolfskinTokens.Library.borderWidth).padding(D.statePaddingX, D.statePaddingY)) {
Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
Glyph(if (signIn) "profile" else "alert", if (signIn) C.accentText else C.warn, Modifier.size(L.buttonIcon))
Text(if (signIn) "Discover needs sign-in" else "Discover is resting", color = C.fg, style = WolfskinTokens.Type.stateTitle)

View file

@ -55,7 +55,6 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
val active = state.contentFocused && state.focusedRow == row
val slots = ((L.contentWidth + K.posterGap) / K.posterPitch).toInt()
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
val density = LocalDensity.current
var initialized by remember { mutableStateOf(false) }
LaunchedEffect(column, count) {
var target = first
@ -91,7 +90,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
})
}
if (lane.tail != DiscoverTail.None) item(key = "tail:${lane.id}", contentType = if (lane.tail == DiscoverTail.SeeAll) "seeAll" else "tail") {
DiscoverTailCard(lane, active && column == lane.titles.size) { state.focus(row, lane.titles.size); state.key(ShellKey.Ok, true); state.key(ShellKey.Ok, false) }
DiscoverTailCard(lane, active && column == lane.titles.size) { state.focus(row, lane.titles.size); state.key(ShellKey.Ok) }
}
}
}
@ -99,8 +98,10 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
val item = lane.titles.getOrNull(column)
Caption(
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else listOf(CaptionFact(if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles")),
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), L.rowHeadingBand + K.posterH + L.captionOffset).width(D.captionWidth),
accentFirstFact = lane.hidden,
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
)
}
}
@ -109,30 +110,10 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
/** Only the focused poster reads the ratings plate; cached observations paint immediately. */
@Composable
fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, hidden: Boolean, modifier: Modifier = Modifier) {
val plate = focused && state.plateKey == title.key
Box(modifier.size(K.posterW, K.posterH).zIndex(if (focused) 1f else 0f)
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)) {
Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(discoverFallback(title.title))) {
Text(title.title, Modifier.padding(L.buttonGap), color = C.fg, style = WolfskinTokens.Type.button, maxLines = 4)
DiscoverArtwork(title.poster, Modifier.fillMaxSize(), poster = true)
if (plate && state.isSignedIn && title.ratings.isNotEmpty()) {
Box(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = .9f), C.bg.copy(alpha = .72f), C.bg.copy(alpha = 0f)))).padding(start = D.plateSide, end = D.plateSide, top = D.plateTop, bottom = D.plateFade)) {
DiscoverRatings(title.ratings, Modifier.fillMaxWidth(), plate = true)
}
}
val receipts = state.requests[title.key].orEmpty()
val held = state.shell.person.key in title.heldBy
val failed = receipts.isNotEmpty() && receipts.all { it.stage == "Failed" }
if (held || receipts.isNotEmpty()) {
Box(Modifier.align(Alignment.BottomEnd).offset(-L.badgeInset, -L.badgeInset).size(L.badge)
.background(if (held) C.ok else C.badgeRest, CircleShape)
.border(if (held) WolfskinTokens.Shell.zero else D.badgeRing, if (failed) C.fail else C.accent, CircleShape), contentAlignment = Alignment.Center) {
Glyph(if (held) "check" else if (failed) "alert" else "clock", if (held) C.onFocus else if (failed) C.fail else C.accent, Modifier.size(D.badgeIcon))
}
}
}
}
val receipts = state.requests[title.key]
val card = remember(title, receipts, state.shell.person.key) { title.libraryCard(state) }
LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true,
modifier.zIndex(if (focused) 1f else 0f), offlineArt = LocalDiscoverArt.current)
}
@Composable
@ -149,17 +130,7 @@ internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: ()
}
}
internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): List<CaptionFact> = buildList {
if (hidden) add(CaptionFact("Hidden from your picks", true))
title.year?.let { add(CaptionFact(it.toString())) }
add(CaptionFact(if (title.type == "tv") title.seasonCount?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series" else title.runtimeSeconds?.let { "${it / 3600}h ${(it / 60) % 60}m" } ?: "Movie"))
val receipts = state.requests[title.key].orEmpty()
if (state.shell.person.key in title.heldBy) add(CaptionFact("In your library", true))
else if (receipts.isNotEmpty()) {
val recipients = receipts.joinToString(", ") { request -> if (request.requester == state.shell.person.key) "you" else state.shell.people.firstOrNull { it.key == request.requester }?.name ?: request.requester.orEmpty() }
add(CaptionFact("Requested for $recipients · ${receipts.first().stage}", true))
} else if (title.heldBy.isNotEmpty()) {
val names = title.heldBy.joinToString(" and ") { key -> state.shell.people.firstOrNull { it.key == key }?.name ?: key }
add(CaptionFact("In $names’s library", true))
}
internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String {
val card = title.libraryCard(state)
return (if (hidden) "Hidden from your picks · " else "") + card.facts(true)
}

View file

@ -19,7 +19,7 @@ class DiscoverState(
private val onPush: (String) -> Unit = {},
private val onPop: () -> Unit = {},
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
) {
) : ShellContentFocus {
var snapshot by mutableStateOf(initial)
private set
private var serviceFilter by mutableStateOf("movie")
@ -34,7 +34,7 @@ class DiscoverState(
set(value) { shell.rememberedView("discover").cards["slide"] = value }
var title by mutableStateOf<DiscoverTitle?>(null)
var service by mutableStateOf<DiscoverService?>(null)
var collection by mutableStateOf<DiscoverCollectionState?>(null)
var collection by mutableStateOf<LibraryGridState?>(null)
var titleButton by mutableStateOf(0)
private var activeOverlay by mutableStateOf<DiscoverOverlayState?>(null)
var overlay: DiscoverOverlayState?
@ -46,7 +46,6 @@ class DiscoverState(
val requests = mutableStateMapOf<String, List<DiscoverRequestResult>>()
private val requestSheets = mutableMapOf<Pair<String, String>, RequestSheetState>()
private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } }
private var armedCard: String? = null
private var revision by mutableStateOf(0)
private var cachedRevision = -1
private var cachedFilter = ""
@ -90,6 +89,12 @@ class DiscoverState(
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
override val cardKey: String? get() = when {
overlay != null || title != null -> null
collection != null -> collection?.cardKey
isCard -> "$rowKey:$column"
else -> null
}
fun focus(row: Int, column: Int = 0) {
shell.navFocused = false; shell.view.row = row
@ -108,11 +113,15 @@ class DiscoverState(
}
fun openTitle(item: DiscoverTitle) {
title = item; titleButton = 0; armedCard = null
title = item; titleButton = 0
onPush("discover-title/${item.key}")
}
fun openCollection(lane: DiscoverLane) {
collection = DiscoverCollectionState(shell, lane, ::openTitle, ::refresh) { collection = null; onPop() }
val titles = lane.fullTitles.associateBy { it.key }
collection = LibraryGridState(lane.title, lane.fullTitles.map { it.libraryCard(this) }, catalogue = true,
onTitle = { openTitle(titles.getValue(it.id)) }, onRecover = ::refresh).apply {
tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
}
onPush("discover-see-all/${lane.id}")
}
fun isHidden(item: DiscoverTitle): Boolean = item.key in hidden
@ -129,25 +138,15 @@ class DiscoverState(
else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null
}
/** The shell consumes repeat events before this handler; rows activate only on key-up. */
fun key(key: ShellKey, down: Boolean): Boolean {
if (overlay != null) {
if (down) overlayKey(key)
return true
}
/** Card hold/release handling belongs to the shell, including the shared catalogue grid. */
override fun key(key: ShellKey): Boolean {
if (overlay != null) { overlayKey(key); return true }
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false
if (snapshot.status == DiscoverStatus.Loading) { if (down) shell.focusNav(shell.currentTab); return true }
if (title == null) collection?.let { return it.key(key, down) }
if (isCard && key == ShellKey.Ok) {
val identity = "$rowKey:$column"
if (down) armedCard = identity else {
if (armedCard == identity) activateCard()
armedCard = null
}
return true
if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true }
if (title == null) collection?.let {
if (key == ShellKey.Back) { collection = null; onPop(); return true }
return it.key(key)
}
if (!down) return true
armedCard = null
if (key == ShellKey.Back) {
if (title != null) { title = null; onPop() }
else if (service != null) { service = null; filter = rootFilter; revision++; onPop() }
@ -164,6 +163,7 @@ class DiscoverState(
else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn())
}
ShellKey.Ok -> when {
isCard -> activateCard()
!isSignedIn || snapshot.status == DiscoverStatus.Unavailable -> refresh()
focusedRow == 0 -> hero?.let(::openTitle)
focusedRow == 1 -> { filter = (if (service == null) DISCOVER_FILTERS else listOf("movie", "tv"))[column]; if (service == null) slide = 0; revision++ }

View file

@ -38,10 +38,10 @@ fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier
if (!state.canRequest) DiscoverButton("Try again", focused) { state.refresh() } else {
val receipt = state.requests[title.key]?.firstOrNull { it.requester == state.shell.person.key }
DiscoverButton(if (receipt == null) "Request" else "Requested · ${receipt.stage}", focused && state.titleButton == 0, glyph = if (receipt == null) "plus" else "clock") {
state.titleButton = 0; state.key(ShellKey.Ok, true)
state.titleButton = 0; state.key(ShellKey.Ok)
}
DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, glyph = "profile", primary = false) { state.openRequest(true) }
if (state.isPick(title)) DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, glyph = if (state.isHidden(title)) "undo" else "hide", primary = false) { state.titleButton = 2; state.key(ShellKey.Ok, true) }
if (state.isPick(title)) DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, glyph = if (state.isHidden(title)) "undo" else "hide", primary = false) { state.titleButton = 2; state.key(ShellKey.Ok) }
}
}
}

View file

@ -26,6 +26,8 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
"hide" -> R.drawable.wolfskin_hide
"undo" -> R.drawable.wolfskin_undo
"left" -> R.drawable.wolfskin_left
"film" -> R.drawable.wolfskin_film
"eye" -> R.drawable.wolfskin_eye
"down" -> R.drawable.wolfskin_down
else -> R.drawable.wolfskin_right
}

View file

@ -32,8 +32,8 @@ fun Wordmark(modifier: Modifier = Modifier) {
/** Initials are drawn over opaque person colour at the exact slot size. */
@Composable
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier) {
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus) {
Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) {
Text(person.initial, color = WolfskinTokens.Color.onFocus, style = textStyle)
Text(person.initial, color = letterColor, style = textStyle)
}
}

View file

@ -0,0 +1,56 @@
package org.justinvisser.wolfskin.design
import org.json.JSONArray
import org.json.JSONObject
/** The locked fixture is supplied by debug assets or JVM resources, never a release fallback. */
class LibraryFixture(json: String, private val posterPrefix: String) {
private val document = JSONObject(json)
private val profiles = document.getJSONArray("profiles")
private val people = (0 until profiles.length()).associate { profiles.getJSONObject(it).let { person -> person.getString("key") to person } }
private val holders = buildMap<String, MutableList<String>> {
people.forEach { (key, person) ->
for (destination in listOf("movies", "shows", "anime")) {
val library = person.optJSONArray(destination) ?: continue
for (index in 0 until library.length()) {
val item = library.getJSONObject(index)
getOrPut("${item.optString("type")}:${item.optString("tmdbId")}") { mutableListOf() }.add(key.replaceFirstChar { it.uppercase() })
}
}
}
}
fun library(person: String, destination: String): List<LibraryTitle> = readTitles(people.getValue(person).optJSONArray(destination) ?: JSONArray())
fun picks(person: String, type: String): List<LibraryTitle> = readTitles(people.getValue(person).getJSONArray("discover")).filter { it.type == type }
fun trending(): List<LibraryTitle> = readTitles(document.getJSONObject("seeAll").getJSONArray("trending"))
fun duplicatePage(): List<LibraryTitle> = readTitles(document.getJSONObject("seeAll").getJSONArray("duplicate"))
private fun readTitles(array: JSONArray): List<LibraryTitle> = List(array.length()) { index ->
val item = array.getJSONObject(index)
val type = item.getString("type")
val ratings = item.optJSONObject("ratings")
val owners = holders["$type:${item.optString("tmdbId")}"].orEmpty()
val mine = item.optString("availability") == "in_library"
LibraryTitle(
id = item.getString("id"), title = item.getString("title"), type = type,
year = item.optInt("year").takeIf { it > 0 }, runtimeSeconds = item.optLong("runtimeSeconds").takeIf { it > 0 },
seasons = item.optInt("seasonCount").takeIf { it > 0 },
poster = item.optString("poster").takeIf { it.isNotBlank() && it != "null" }?.let { posterPrefix + it },
played = item.optBoolean("played"), progress = item.optDouble("progress", 0.0).toFloat(),
addedAt = item.optString("addedAt"), ratings = buildList {
fun append(source: String, field: String, label: String? = null) {
val score = ratings?.optDouble(field, Double.NaN) ?: Double.NaN
val maximum = if (source.startsWith("rt")) 100 else 10
if (score.isFinite() && score in 0.0..maximum.toDouble()) add(LibraryRating(source, score, label))
}
append("rtCritics", "rtCritics", ratings?.optString("rtCriticsLabel"))
append("rtAudience", "rtAudience", ratings?.optString("rtAudienceLabel"))
if (type == "movie" && (ratings?.optInt("imdbVotes", 0) ?: 0) > 0) append("imdb", "imdb")
if (isEmpty() && (ratings?.optInt("tmdbVotes", 0) ?: 0) > 0) append("tmdb", "tmdb")
},
availability = if (mine) "In your library" else if (owners.isNotEmpty()) "In ${owners.joinToString(" and ")}’s library" else "",
badge = if (mine) "held" else null,
tmdbId = item.optString("tmdbId"),
)
}
}

View file

@ -0,0 +1,156 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.derivedStateOf
import java.util.Locale
private val leadingArticle = Regex("^(the|a|an)\\s+", RegexOption.IGNORE_CASE)
/** Immutable title facts cross the provider boundary without SDK objects. */
data class LibraryTitle(
val id: String,
val title: String,
val type: String,
val year: Int? = null,
val runtimeSeconds: Long? = null,
val seasons: Int? = null,
val poster: String? = null,
val played: Boolean = false,
val progress: Float = 0f,
val addedAt: String = "",
val ratings: List<LibraryRating> = emptyList(),
val availability: String = "",
val badge: String? = null,
val tmdbId: String? = null,
) {
val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT)
val catalogueKey: String = "$type:${tmdbId ?: id}"
val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#'
val leadScore: Double get() = ratings.firstOrNull()?.let { if (it.source.startsWith("rt")) it.score else it.score * 10 } ?: -1.0
fun facts(catalogue: Boolean): String = buildList {
year?.let { add(it.toString()) }
add(if (type == "movie") runtimeSeconds?.let { val minutes = (it + 30) / 60; if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" } ?: "Movie" else seasons?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series")
if (catalogue) { if (availability.isNotEmpty()) add(availability) }
else if (played) add("Watched")
else if (progress > 0f && runtimeSeconds != null) add("${((runtimeSeconds * (1 - progress) + 30) / 60).toInt().coerceAtLeast(1)} min left")
}.joinToString(" · ")
}
/** Ordered rating entries are formatted after the active TV locale is known. */
data class LibraryRating(val source: String, val score: Double, val label: String? = null) {
fun formatted(locale: Locale = Locale.getDefault()): String = if (source.startsWith("rt")) "${score.toInt()}%" else String.format(locale, "%.1f", score)
}
enum class LibraryStatus { Ready, Loading, Unavailable }
enum class GridTail { None, Retry, ShowMore }
/** A grid retains its exact card, toolbar choices and rail return point with the shell view. */
class LibraryGridState(
val title: String,
items: List<LibraryTitle>,
val catalogue: Boolean = false,
val onTitle: (LibraryTitle) -> Unit = {},
val onRecover: () -> Unit = {},
val onDiscover: () -> Unit = {},
val onReadAhead: (Int) -> Unit = {},
) : ShellContentFocus {
var source by mutableStateOf(items)
var status by mutableStateOf(LibraryStatus.Ready)
var savedAt by mutableStateOf<String?>(null)
var sort by mutableStateOf(0)
var unwatched by mutableStateOf(false)
var mediaType by mutableStateOf("all")
var tail by mutableStateOf(GridTail.None)
var pagination: LibraryPagination? = null
var pageRevision by mutableStateOf(0)
var refreshRevision by mutableStateOf(0)
var pointerReentry: (() -> Int?)? = null
var index by mutableStateOf(0)
var toolbar by mutableStateOf(false)
var tool by mutableStateOf(0)
var inRail by mutableStateOf(false)
var railLetter by mutableStateOf('#')
private var railReturn = 0
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" }
val hasToolbar: Boolean get() = !catalogue || mixed
val items: List<LibraryTitle> by derivedStateOf {
val filtered = source.filter { (!unwatched || !it.played) && (mediaType == "all" || it.type == mediaType) }
if (catalogue) filtered else when (sort) {
1 -> filtered.sortedWith(compareByDescending<LibraryTitle> { it.addedAt }.thenBy { it.sortName })
2 -> filtered.sortedWith(compareByDescending<LibraryTitle> { it.leadScore }.thenBy { it.sortName })
else -> filtered.sortedBy { it.sortName }
}
}
val railVisible: Boolean get() = !catalogue && sort == 0 && items.size >= WolfskinTokens.Behaviour.letterRailMin && status == LibraryStatus.Ready
val tailIndex: Int get() = ((items.size + columns - 1) / columns) * columns
override val cardKey: String? get() = if (toolbar || inRail || status != LibraryStatus.Ready || items.isEmpty()) null else items.getOrNull(index)?.id ?: "grid-tail"
val focusedTitle: LibraryTitle? get() = if (toolbar || inRail) null else items.getOrNull(index)
override fun key(key: ShellKey): Boolean {
if (inRail) return railKey(key)
if (key == ShellKey.Back) return false
if (key == ShellKey.Up || key == ShellKey.Down || key == ShellKey.Left || key == ShellKey.Right) {
val reentry = pointerReentry
pointerReentry = null
reentry?.invoke()?.let { index = it; toolbar = false; return true }
}
if (status == LibraryStatus.Loading) return false
if (status == LibraryStatus.Unavailable || (items.isEmpty() && !toolbar)) {
when (key) {
ShellKey.Up -> if (status == LibraryStatus.Ready && hasToolbar) toolbar = true else return false
ShellKey.Ok -> if (status == LibraryStatus.Unavailable) onRecover() else if (unwatched) { unwatched = false; index = 0 } else onDiscover()
else -> Unit
}
return true
}
if (toolbar) return toolbarKey(key)
val currentItems = items
when (key) {
ShellKey.Up -> if (index >= columns) index -= columns else if (hasToolbar) toolbar = true else return false
ShellKey.Down -> when {
index / columns < currentItems.lastIndex / columns -> index = (index + columns).coerceAtMost(currentItems.lastIndex)
tail != GridTail.None && index < tailIndex -> index = tailIndex
}
ShellKey.Left -> if (index % columns > 0) index--
ShellKey.Right -> if (index % columns < columns - 1 && index < currentItems.lastIndex) index++ else if (railVisible) { inRail = true; railReturn = index; railLetter = currentItems[index].letter }
ShellKey.Ok -> currentItems.getOrNull(index)?.let(onTitle) ?: if (pagination != null) pageRevision++ else onRecover()
}
onReadAhead(index)
return true
}
private fun toolbarKey(key: ShellKey): Boolean {
val count = if (catalogue) 3 else 2
when (key) {
ShellKey.Up -> return false
ShellKey.Down -> { toolbar = false; index = index.coerceIn(0, items.lastIndex.coerceAtLeast(0)) }
ShellKey.Left -> tool = (tool - 1).coerceAtLeast(0)
ShellKey.Right -> tool = (tool + 1).coerceAtMost(count - 1)
ShellKey.Ok -> {
if (catalogue) mediaType = listOf("all", "movie", "tv")[tool]
else if (tool == 0) sort = (sort + 1) % 3 else unwatched = !unwatched
index = 0
}
else -> Unit
}
return true
}
private fun railKey(key: ShellKey): Boolean {
val letters = items.map { it.letter }.distinct()
when (key) {
ShellKey.Up, ShellKey.Down -> {
val next = (letters.indexOf(railLetter) + if (key == ShellKey.Up) -1 else 1).coerceIn(0, letters.lastIndex)
railLetter = letters[next]
}
ShellKey.Left, ShellKey.Ok -> { index = items.indexOfFirst { it.letter == railLetter }; inRail = false }
ShellKey.Back -> { index = railReturn; inRail = false }
else -> Unit
}
return true
}
}

View file

@ -0,0 +1,32 @@
package org.justinvisser.wolfskin.design
import android.content.Context
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.intercept.Interceptor
import coil3.memory.MemoryCache
import coil3.request.ImageRequest
import coil3.request.allowRgb565
import coil3.request.crossfade
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import coil3.disk.directory
/** Every grid shares one bounded cache and four concurrent image reads. */
object LibraryImages {
private var loader: ImageLoader? = null
private val reads = Semaphore(WolfskinTokens.Library.imageReads.toInt())
@Synchronized
fun loader(context: Context): ImageLoader = loader ?: SingletonImageLoader.get(context).newBuilder()
.memoryCache(MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * 1024 * 1024).toLong()).build())
.diskCache(DiskCache.Builder().directory(context.cacheDir.resolve("wolfskin-library-art"))
.maxSizeBytes((WolfskinTokens.Image.diskCacheMB * 1024 * 1024).toLong()).build())
.components { add(Interceptor { chain -> reads.withPermit { chain.proceed() } }) }
.crossfade(false).build().also { loader = it }
fun request(context: Context, poster: String): ImageRequest = ImageRequest.Builder(context).data(poster)
.size((WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt())
.allowRgb565(true).crossfade(false).build()
}

View file

@ -0,0 +1,36 @@
package org.justinvisser.wolfskin.design
import kotlinx.coroutines.CancellationException
/** Provider pages are merged by title identity; transport pagination is never displayed. */
data class LibraryPage(val items: List<LibraryTitle>, val hasMore: Boolean)
/** Duplicate-only reads pause until Show more; failures pause until Try again. */
class LibraryPagination(private val grid: LibraryGridState, private val load: suspend (Int) -> LibraryPage) {
private var nextPage = 2
private var hasMore = true
private var reading = false
suspend fun readAhead(force: Boolean = false) {
if (reading || !hasMore || !force && (grid.tail != GridTail.None || grid.index < grid.items.size - WolfskinTokens.Behaviour.nextPageCards)) return
reading = true
try {
val page = load(nextPage)
val known = grid.source.mapTo(mutableSetOf()) { it.catalogueKey }
val additional = page.items.filter { known.add(it.catalogueKey) }
nextPage++
hasMore = page.hasMore
if (additional.isEmpty() && page.items.isNotEmpty() && hasMore) grid.tail = GridTail.ShowMore
else {
grid.source = grid.source + additional
grid.tail = GridTail.None
grid.index = grid.index.coerceAtMost(grid.items.lastIndex.coerceAtLeast(0))
}
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
grid.tail = GridTail.Retry
} finally {
reading = false
}
}
}

View file

@ -0,0 +1,130 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L
/** Poster slots use exact requests and share the shell's draw-only focus treatment. */
@Composable
fun LibraryPoster(
title: LibraryTitle,
focused: Boolean,
ratingsVisible: Boolean,
catalogue: Boolean,
modifier: Modifier = Modifier,
offlineArt: ((String) -> ImageBitmap?)? = null,
) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Box(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.testTag("poster-${title.id}")) {
Box(Modifier.fillMaxSize().clip(shape).background(C.surface2)) {
val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } }
if (bitmap != null) Image(bitmap, title.title, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else if (title.poster != null && offlineArt == null) {
val context = LocalContext.current
val request = remember(title.poster, context) { LibraryImages.request(context, title.poster) }
AsyncImage(request, title.title, imageLoader = LibraryImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
} else MissingLibraryArt(title)
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
if (title.progress > 0f && title.progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap)
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) {
Box(Modifier.fillMaxHeight().fillMaxWidth(title.progress).background(C.accent, CircleShape))
}
}
if (catalogue && title.badge != null) {
val held = title.badge == "held"
val color = if (title.badge == "failed") C.fail else C.accent
Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset)
.size(WolfskinTokens.Layout.badge).background(if (held) C.ok else C.badgeRest, CircleShape)
.border(if (held) WolfskinTokens.Shell.zero else WolfskinTokens.Discover.badgeRing, color, CircleShape), contentAlignment = Alignment.Center) {
Glyph(if (held) "check" else if (title.badge == "failed") "alert" else "clock",
if (held) C.onFocus else color, Modifier.size(WolfskinTokens.Discover.badgeIcon))
}
}
}
}
}
@Composable
private fun MissingLibraryArt(title: LibraryTitle) {
val hue = remember(title.title) { title.title.fold(0) { hash, character -> (hash * L.fallbackHueMultiplier.toInt() + character.code) % 360 }.toFloat() }
Column(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(
Color.hsl(hue, L.fallbackSaturationTop, L.fallbackLightnessTop),
Color.hsl(hue, L.fallbackSaturationBottom, L.fallbackLightnessBottom))))
.padding(L.fallbackPadding), verticalArrangement = Arrangement.SpaceBetween) {
Glyph(if (title.type == "movie") "film" else "nextup", C.fg.copy(alpha = L.fallbackIconOpacity), Modifier.size(L.fallbackIcon))
Text(title.title, style = WolfskinTokens.Type.button, color = C.fg, maxLines = 4)
}
}
/** A measured prefix stays on one line, shrinking only to the approved type floor. */
@Composable
fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifier, visible: Boolean = true) {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val available = with(density) { (WolfskinTokens.Card.posterW - L.plateSide * 2).toPx() }
val formatted = ratings.map { it.formatted() }
fun width(count: Int, size: TextUnit): Float = (0 until count).sumOf { index ->
val labelWidth = measurer.measure(formatted[index], WolfskinTokens.Type.ratingPlate.copy(fontSize = size)).size.width
val pixels = with(density) { size.toPx() }
(labelWidth + pixels * (if (ratings[index].source in listOf("imdb", "tmdb")) L.wordmarkRatio else 1f) + pixels * L.markGapEm + if (index > 0) pixels * L.plateGapEm else 0f).toDouble()
}.toFloat()
var size = WolfskinTokens.Type.ratingPlate.fontSize
var count = ratings.size
while (size > L.plateFloor.fontSize && width(count, size) > available) size = (size.value - L.plateStep).coerceAtLeast(L.plateFloor.fontSize.value).sp
while (count > 1 && width(count, size) > available) count--
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "library-plate")
Row(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }.background(Brush.verticalGradient(
0f to C.bg.copy(alpha = L.plateOpacity), L.plateMidStop to C.bg.copy(alpha = L.plateMidOpacity), 1f to Color.Transparent))
.padding(start = L.plateSide, end = L.plateSide, top = L.plateTop, bottom = L.plateBottom),
horizontalArrangement = Arrangement.spacedBy(with(density) { (size.toPx() * L.plateGapEm).toDp() }), verticalAlignment = Alignment.CenterVertically) {
repeat(count) { index ->
val rating = ratings[index]
val mark = when (rating.source) {
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
"rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
"imdb" -> R.drawable.ic_rating_imdb
else -> R.drawable.ic_rating_tmdb
}
val markHeight = with(density) { size.toDp() }
val unknownCritic = rating.source == "rtCritics" && rating.label != "Fresh" && rating.label != "Certified Fresh" && rating.label != "Rotten"
val desaturated = remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) }
Row(horizontalArrangement = Arrangement.spacedBy(markHeight * L.markGapEm), verticalAlignment = Alignment.CenterVertically) {
Image(androidx.compose.ui.res.painterResource(mark), rating.source,
Modifier.size(markHeight * if (rating.source in listOf("imdb", "tmdb")) L.wordmarkRatio else 1f, markHeight)
.graphicsLayer { this.alpha = if (unknownCritic) L.unknownCriticOpacity else 1f },
colorFilter = if (unknownCritic) desaturated else null)
Text(formatted[index], color = C.fg, style = WolfskinTokens.Type.ratingPlate.copy(fontSize = size), maxLines = 1, softWrap = false)
}
}
}
}

View file

@ -0,0 +1,275 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as G
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
/** One lazy frame owns headers, toolbar, continuous poster rows and recoverable tails. */
@Composable
fun LibraryScreen(
shell: ShellState,
grid: LibraryGridState,
modifier: Modifier = Modifier,
ratingsSignedIn: Boolean = false,
cachedRatings: Set<String> = emptySet(),
ratingsReachable: Boolean = true,
offlineArt: ((String) -> ImageBitmap?)? = null,
registerFocus: Boolean = true,
) {
val view = shell.view
val density = LocalDensity.current
val context = LocalContext.current
val active = !shell.navFocused && !shell.pickerVisible
val items = grid.items
val focused = grid.focusedTitle
var plateKey by remember { mutableStateOf<String?>(null) }
DisposableEffect(shell, grid, registerFocus) {
if (registerFocus) shell.contentFocus = grid
onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null }
}
LaunchedEffect(grid) {
var revision = grid.pageRevision
snapshotFlow { Triple(grid.index, grid.source.size, grid.pageRevision) }.collect { (_, _, nextRevision) ->
grid.pagination?.readAhead(force = nextRevision != revision)
revision = nextRevision
}
}
LaunchedEffect(grid.index / grid.columns, items, offlineArt) {
if (offlineArt == null) {
val start = (grid.index / grid.columns + 1) * grid.columns
for (index in start until (start + grid.columns).coerceAtMost(items.size)) {
items[index].poster?.let { LibraryImages.loader(context).enqueue(LibraryImages.request(context, it)) }
}
}
}
LaunchedEffect(focused?.id, active, ratingsSignedIn, ratingsReachable) {
plateKey = null
if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings)) {
if (focused.id !in cachedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong())
plateKey = focused.id
}
}
LaunchedEffect(grid.status) {
if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab)
}
val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
LaunchedEffect(grid.index, grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) {
val index = if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index
val row = index.coerceAtLeast(0) / grid.columns
if (grid.toolbar || row == 0 && active) {
animateLibraryScroll(view, headerCount, 0f, density)
} else if (row > 0) {
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() }
animateLibraryScroll(view, headerCount, target, density)
}
}
Box(modifier.semantics { stateDescription = "${grid.title}:${if (grid.toolbar) "tool-${grid.tool}" else if (grid.inRail) "rail-${grid.railLetter}" else focused?.id ?: "recovery"}" }.testTag("library-grid")) {
LazyColumn(state = view.listState, modifier = Modifier.fillMaxSize().pointerInput(grid, headerCount) {
awaitPointerEventScope {
while (true) {
if (awaitPointerEvent().type == PointerEventType.Scroll) grid.pointerReentry = {
val visible = view.listState.layoutInfo.visibleItemsInfo
if (visible.none { it.index == headerCount + grid.index / grid.columns }) {
visible.firstOrNull { it.index >= headerCount }?.let { ((it.index - headerCount) * grid.columns).coerceAtMost(grid.items.lastIndex) }
} else null
}
}
}
}, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine - rowHeight)) {
item(key = "header", contentType = "header") { LibraryHeader(grid, shell.person) }
if (headerCount == 2) item(key = "toolbar", contentType = "chips") { LibraryToolbar(grid, active) }
when (grid.status) {
LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
Box(Modifier.fillMaxWidth().height(rowHeight)) {
Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) }
}
}
}
LibraryStatus.Unavailable -> item(key = "unavailable", contentType = "state") { LibraryRecovery(grid, active) }
LibraryStatus.Ready -> if (items.isEmpty()) item(key = "empty", contentType = "state") { LibraryRecovery(grid, active && !grid.toolbar) }
else {
items((items.size + grid.columns - 1) / grid.columns, key = { row -> "grid-${items[row * grid.columns].id}" }, contentType = { "grid" }) { row ->
Box(Modifier.fillMaxWidth().height(rowHeight)) {
Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
for (column in 0 until grid.columns) {
val index = row * grid.columns + column
val title = items.getOrNull(index) ?: break
key(if (grid.catalogue) title.catalogueKey else title.id) {
val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index
LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue,
Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if ((event.type == PointerEventType.Move || event.type == PointerEventType.Enter) && event.changes.any { it.type == PointerType.Mouse }) {
grid.index = index; grid.toolbar = false; shell.navFocused = false
}
}
}
}.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt)
}
}
}
if (active && !grid.toolbar && !grid.inRail && grid.index / grid.columns == row) {
val title = items.getOrNull(grid.index)
if (title != null) Caption(title.title, title.facts(grid.catalogue), true,
Modifier.offset(L.safeSide + K.posterPitch * (grid.index % grid.columns), K.posterH + L.captionOffset).width(G.captionWidth),
accentLastFact = if (grid.catalogue) title.availability.isNotEmpty() else title.played || title.progress > 0f)
}
}
}
if (grid.tail != GridTail.None) item(key = "tail", contentType = "grid") {
LibraryTail(grid, active && grid.index == grid.tailIndex)
}
}
}
}
if (grid.railVisible) LibraryRail(grid)
}
}
@Composable
private fun LibraryHeader(grid: LibraryGridState, person: ShellPerson) {
val count = if (grid.catalogue) grid.items.size else grid.source.size
Box(Modifier.fillMaxWidth().height(L.row_lhead)) {
Row(Modifier.offset(L.safeSide, L.navBand), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsHeaderGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
Text(grid.title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
Text(if (grid.status != LibraryStatus.Ready) "" else "$count ${if (grid.catalogue) "titles" else if (grid.title == "Movies") if (count == 1) "film" else "films" else "series"}", modifier = Modifier.alignByBaseline(), color = C.muted, style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal))
grid.savedAt?.let { Text("Saved $it", color = C.warn, style = WolfskinTokens.Type.caption) }
}
}
}
@Composable
private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) {
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) {
val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All")
labels.forEachIndexed { index, label ->
val focused = active && grid.toolbar && grid.tool == index
val selected = if (grid.catalogue) listOf("all", "movie", "tv")[index] == grid.mediaType else index == 1 && grid.unwatched
Row(Modifier.height(G.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, CircleShape)
.then(if (selected && !focused) Modifier.border(G.borderWidth, C.accent, CircleShape) else Modifier)
.clickable { grid.toolbar = true; grid.tool = index; grid.key(ShellKey.Ok) }
.padding(horizontal = G.chipPadding), horizontalArrangement = Arrangement.spacedBy(G.chipContentGap), verticalAlignment = Alignment.CenterVertically) {
val color = if (focused) C.onFocus else if (selected) C.accentText else C.fg
if (!grid.catalogue) Glyph(if (index == 0) "advanced" else "eye", color, Modifier.size(G.chipIcon))
if (!grid.catalogue && index == 0) Text(buildAnnotatedString {
withStyle(SpanStyle(color = color.copy(alpha = G.chipLabelOpacity), fontWeight = FontWeight.Medium)) { append("Sort") }
append(" ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}")
}, color = color, style = WolfskinTokens.Type.label)
else Text(label, color = color, style = WolfskinTokens.Type.label)
}
}
}
}
@Composable
private fun LibraryRecovery(grid: LibraryGridState, focused: Boolean) {
val lineBoxes = androidx.compose.ui.text.style.LineHeightStyle(
androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
androidx.compose.ui.text.style.LineHeightStyle.Trim.None,
)
val unavailable = grid.status == LibraryStatus.Unavailable
val title = if (unavailable) "Couldn’t reach your library" else if (grid.unwatched) "Nothing unwatched in ${grid.title}" else "Nothing in ${grid.title} yet"
val text = if (unavailable) "Jellyfin isn’t answering. Nothing here is lost; try again in a moment." else if (grid.unwatched) "Everything here has been watched." else "Titles you or others request for you land here."
val button = if (unavailable) "Try again" else if (grid.unwatched) "Show all" else "Open Discover"
Box(Modifier.fillMaxWidth().height(L.row_state).padding(start = L.safeSide, top = G.panelTop)) {
Column(Modifier.width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.border(G.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(G.borderWidth)
.padding(horizontal = G.panelPaddingX, vertical = G.panelPaddingY)) {
Row(horizontalArrangement = Arrangement.spacedBy(G.panelIconGap), verticalAlignment = Alignment.CenterVertically) {
Glyph(if (unavailable) "alert" else if (grid.title == "Movies") "film" else "nextup", if (unavailable) C.warn else C.accentText, Modifier.size(WolfskinTokens.Shell.panelIcon))
Text(title, color = C.fg, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold, lineHeightStyle = lineBoxes))
}
Spacer(Modifier.height(G.panelTitleGap))
Text(text, color = C.muted, style = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBoxes))
Spacer(Modifier.height(G.panelTextGap))
PickerButton(button, focused, !unavailable) { grid.key(ShellKey.Ok) }
}
}
}
@Composable
private fun LibraryTail(grid: LibraryGridState, focused: Boolean) {
val retry = grid.tail == GridTail.Retry
Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) {
Column(Modifier.offset(L.safeSide).size(K.posterW, K.posterH).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)).border(G.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.card))
.clickable { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }.padding(G.tailPadding),
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))
Text(if (retry) "Try again" else "Show more", color = C.fg, style = WolfskinTokens.Type.button, textAlign = TextAlign.Center)
Text(if (retry) "More titles couldn’t load" else "Only titles already shown came back", color = C.muted, style = WolfskinTokens.Type.caption, textAlign = TextAlign.Center)
}
Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused,
Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth))
}
}
@Composable
private fun BoxScope.LibraryRail(grid: LibraryGridState) {
Column(Modifier.align(Alignment.TopEnd).padding(end = L.safeSide, top = L.navBand).width(G.railWidth)) {
val letters = remember(grid.items) { grid.items.map { it.letter }.toSet() }
for (letter in "#ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
val current = if (grid.inRail) grid.railLetter == letter else grid.focusedTitle?.letter == letter
Box(Modifier.width(G.railWidth).height(if (current && grid.inRail) G.railFocusHeight else G.railLetterHeight)
.background(if (current && grid.inRail) C.fg else androidx.compose.ui.graphics.Color.Transparent, CircleShape)
.clickable(enabled = letter in letters) { grid.railLetter = letter; grid.inRail = true; grid.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
Text(letter.toString(), color = if (current) if (grid.inRail) C.onFocus else C.accent else if (letter in letters) C.muted else C.railEmpty,
style = if (current && grid.inRail) G.railFocusedLetter else G.railLetter)
}
}
}
}
private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int, target: Float, density: androidx.compose.ui.unit.Density) {
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
val first = view.listState.firstVisibleItemIndex
val current = with(density) {
(if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead +
if (first < headerCount) WolfskinTokens.Shell.zero else
(if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
} + view.listState.firstVisibleItemScrollOffset
val distance = target - current
if (distance == 0f) return
view.listState.scroll {
var previous = 0f
androidx.compose.animation.core.animate(0f, distance,
animationSpec = androidx.compose.animation.core.tween(WolfskinTokens.Motion.scroll,
easing = androidx.compose.animation.core.CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ ->
scrollBy(value - previous)
previous = value
}
}
}

View file

@ -0,0 +1,27 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.tv.material3.Text
/** Shelves expose the full underlying list without displaying transport pagination. */
@Composable
fun LibrarySeeAllCard(count: Int, focused: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier,
width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Column(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent)
.background(WolfskinTokens.Color.surface, shape).border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape)
.clickable(onClick = onClick), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge))
Text("See all", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.listTitle)
Text(count.toString(), color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.label)
}
}

View file

@ -22,11 +22,18 @@ sealed interface ShellAction {
enum class ShellKey { Up, Down, Left, Right, Ok, Back }
enum class PeopleStatus { Ready, Loading, Unavailable }
/** Screens participate in the shell's hold guard and Back ladder without a second key adapter. */
interface ShellContentFocus {
val cardKey: String?
fun key(key: ShellKey): Boolean
}
/** A root destination retains focus, horizontal tracks and its actual Compose scroller. */
class RememberedView {
val listState = LazyListState()
var row by mutableStateOf(0)
val cards = mutableStateMapOf<String, Int>()
var library: LibraryGridState? = null
}
/** Focus and Back transitions are independent of drawing and server availability. */
@ -51,11 +58,11 @@ class ShellState(
var handOff by mutableStateOf<String?>(null)
var settingsRow by mutableStateOf(0)
var contentOverlayVisible by mutableStateOf(false)
var contentInput: ((ShellKey, Boolean) -> Boolean)? = null
private var rootRoute by mutableStateOf("home")
private var pickerReturnRoute = "home"
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
private var armedCard: String? = null
var contentFocus: ShellContentFocus? = null
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
@ -76,10 +83,12 @@ class ShellState(
fun focusPerson(index: Int) { pickerIndex = index; gearFocused = false }
/** Repeats are consumed, and a row activation is cancelled when focus moves. */
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = null): Boolean {
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = if (!pickerVisible && !navFocused && !requestsVisible && !contentOverlayVisible) contentFocus?.cardKey else null): Boolean {
if (key == ShellKey.Ok && repeat > 0) return true
if (!pickerVisible && !requestsVisible && (contentOverlayVisible || !navFocused) &&
contentInput?.invoke(key, down) == true) return true
if (!pickerVisible && !requestsVisible && contentOverlayVisible) {
if (down) contentFocus?.key(key)
return true
}
if (key == ShellKey.Ok) {
if (repeat > 0) return true
if (card != null) {
@ -163,6 +172,7 @@ class ShellState(
}
private fun contentKey(key: ShellKey) {
if (contentFocus?.key(key) == true) return
when (key) {
ShellKey.Up -> if (route == "settings" && settingsRow > 0) settingsRow-- else { navFocused = true; navIndex = currentTab }
ShellKey.Down -> if (route == "settings") settingsRow = (settingsRow + 1).coerceAtMost(SETTINGS_GROUPS.lastIndex)
@ -194,10 +204,12 @@ class ShellState(
onAction(ShellAction.HandOff(group))
}
}
else contentFocus?.key(ShellKey.Ok)
}
/** Back never discards a root view, including the card below a focused nav. */
fun back() {
if (!pickerVisible && !navFocused && contentFocus?.key(ShellKey.Back) == true) return
if (route == "settings") {
route = pickerReturnRoute
onAction(ShellAction.Pop)

View file

@ -285,6 +285,53 @@ object WolfskinTokens {
const val navBandOpacity = 0.72f
const val navSolidStop = 0.84f
}
object Library {
val chipHeight = 34.0.dp
val chipPadding = 14.0.dp
val chipGap = 8.0.dp
val chipTop = 8.0.dp
val chipIcon = 13.0.dp
val captionWidth = 320.0.dp
const val captionSeparatorOpacity = 0.4f
val plateTop = 7.0.dp
val plateSide = 8.0.dp
val plateBottom = 23.0.dp
val plateFloor = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
const val plateStep = 0.25f
const val plateGapEm = 0.64f
const val markGapEm = 0.29f
const val wordmarkRatio = 1.8f
const val plateOpacity = 0.9f
const val plateMidOpacity = 0.72f
const val plateMidStop = 0.52f
val railWidth = 26.0.dp
val railLetterHeight = 15.0.dp
val railFocusHeight = 22.0.dp
val railLetter = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val railFocusedLetter = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 18.2.sp, letterSpacing = 0.0.em)
val panelPaddingX = 20.0.dp
val panelPaddingY = 17.0.dp
val panelTextGap = 12.0.dp
val panelTitleGap = 4.0.dp
val panelIconGap = 7.0.dp
val panelTop = 10.0.dp
val borderWidth = 1.0.dp
val chipContentGap = 6.0.dp
const val chipLabelOpacity = 0.7f
val tailIcon = 22.0.dp
val tailPadding = 10.0.dp
val tailGap = 5.0.dp
const val imageReads = 4.0f
const val unknownCriticOpacity = 0.55f
val fallbackPadding = 11.0.dp
val fallbackIcon = 22.0.dp
const val fallbackHueMultiplier = 31.0f
const val fallbackSaturationTop = 0.22f
const val fallbackLightnessTop = 0.22f
const val fallbackSaturationBottom = 0.26f
const val fallbackLightnessBottom = 0.1f
const val fallbackIconOpacity = 0.45f
}
object Discover {
val sheetPaddingX = 32.0.dp
val sheetPaddingY = 40.0.dp
@ -340,5 +387,14 @@ object WolfskinTokens {
val tailIcon = 22.0.dp
val seeAllGap = 4.0.dp
val tailDescription = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(500), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val sheetSubTop = -4.0.dp
val sheetRatingTop = -5.0.dp
val sheetRatingBottom = 3.0.dp
val sheetLabelTop = 4.0.dp
val ambientInset = 40.0.dp
val ambientBlur = 30.0.dp
const val ambientScale = 1.1f
const val ambientSaturation = 1.2f
const val ambientBrightness = 0.55f
}
}

View file

@ -0,0 +1 @@
<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="M2,12 C2,12 5.6,5 12,5 C18.4,5 22,12 22,12 C22,12 18.4,19 12,19 C5.6,19 2,12 2,12 M15,12 A3,3 0,1 1,9 12 A3,3 0,1 1,15 12" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round"/></vector>

View file

@ -0,0 +1 @@
<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 L18.5,4 Q21,4 21,6.5 L21,17.5 Q21,20 18.5,20 L5.5,20 Q3,20 3,17.5 L3,6.5 Q3,4 5.5,4 M7,4 L7,20 M17,4 L17,20 M3,9 L7,9 M3,15 L7,15 M17,9 L21,9 M17,15 L21,15" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round"/></vector>