Fix TV artwork, episode layout and focus visibility

This commit is contained in:
justinzeus 2026-11-06 20:25:50 +01:00
parent 34087d8eb5
commit 9d2471c5b1
28 changed files with 466 additions and 117 deletions

View file

@ -50,8 +50,11 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
}) else null }
val context = LocalContext.current
val request = remember(context, path, poster) {
WolfskinImages.request(context, path, if (poster) 256 else 1280, if (poster) 384 else 720, poster)
val request = remember(context, path, poster, dim) {
val upright = poster || dim
WolfskinImages.request(context, path,
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
}
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
else AsyncImage(

View file

@ -86,7 +86,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero)
if (!state.hasCatalogue) add(L.row_state) else {
if (note) add(L.row_note)
state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
state.lanes.forEach { lane -> add(D.shelfHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
}
}
}

View file

@ -1,9 +1,6 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -22,15 +19,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
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.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
@ -47,27 +42,20 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0))
val active = state.contentFocused && state.focusedRow == row
val slots = ((L.contentWidth + K.posterGap) / K.posterPitch).toInt()
val slots = K.posterSlots
val first = state.shell.view.cards["first:${lane.id}"] ?: 0
var initialized by remember { mutableStateOf(false) }
LaunchedEffect(column, count) {
var target = first
if (column > target + slots - 2) target = column - (slots - 2)
if (column < target + 1) target = (column - 1).coerceAtLeast(0)
target = target.coerceIn(0, (count - slots).coerceAtLeast(0))
val density = LocalDensity.current
LaunchedEffect(column, count, active) {
val target = shelfFirst(column, first, slots, count)
state.shell.view.cards["first:${lane.id}"] = target
val offset = cards.layoutInfo.visibleItemsInfo.firstOrNull { it.index == target }?.offset
if (initialized && offset != null) {
if (offset != 0) cards.animateScrollBy(offset.toFloat(), tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
} else cards.scrollToItem(target)
initialized = true
cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() })
}
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1)
LazyRow(state = cards, userScrollEnabled = false,
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
@ -97,7 +85,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
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 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(WolfskinTokens.Library.captionWidth),
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
accentFirstFact = lane.hidden,
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
)

View file

@ -107,9 +107,5 @@ internal val HomeRow.focusSlots: Int get() = when (cardKind) {
}
/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int {
var first = previous
if (column > first + focusSlots - 2) first = column - (focusSlots - 2)
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0))
}
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int =
shelfFirst(column, previous, focusSlots, count)

View file

@ -61,25 +61,17 @@ fun HomeScreen(
LaunchedEffect(view.row, snapshot.rows) {
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) }
}
LaunchedEffect(view) {
if (view.home.positionedKey != null) {
view.home.focusedKey?.let { view.home.tracks[it] }?.let { track ->
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
}
view.listState.scrollToItem(view.listState.firstVisibleItemIndex, view.listState.firstVisibleItemScrollOffset)
}
}
LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) {
LaunchedEffect(view, view.row, view.home.resetScroll, heights) {
val key = snapshot.rows.getOrNull(view.row)?.id
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect
val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll
val target = if (topRequested) 0f else with(density) {
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
}.coerceAtLeast(0f)
view.home.positionedKey = key
view.home.positionedReset = view.home.resetScroll
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
view.home.positionedKey = key
view.home.positionedReset = view.home.resetScroll
}
val cache = view.home.ratings
val ratingsReader = LocalScreenRatings.current

View file

@ -2,8 +2,6 @@ package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
@ -16,9 +14,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collectLatest
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@ -43,18 +40,12 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
val targetFirst by rememberUpdatedState(first)
val density = LocalDensity.current
LaunchedEffect(track) {
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
var positionedFirst = targetFirst
snapshotFlow { targetFirst }.collectLatest { target ->
if (target != positionedFirst) {
positionedFirst = target
val pitch = with(density) { (width + K.posterGap).toPx() }
track.animateScrollBy((target - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base))
}
}
var positioned by remember(track) { mutableStateOf(false) }
LaunchedEffect(track, first, focused, width, density) {
if (!positioned) track.scrollToItem(first)
else track.scrollShelfTo(first, with(density) { (width + K.posterGap).toPx() })
positioned = true
}
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))

View file

@ -145,7 +145,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
}
itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
row.forEachIndexed { column, item ->
val index = rowIndex * columns + column
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }

View file

@ -0,0 +1,22 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.lazy.LazyListState
/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */
internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int {
var first = previous
if (column > first + slots - 2) first = column - (slots - 2)
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
return first.coerceIn(0, (count - slots).coerceAtLeast(0))
}
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
if (distance != 0f) animateScrollBy(distance, tween(WolfskinTokens.Motion.base,
easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
}

View file

@ -98,14 +98,14 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat()
ColorFilter.colorMatrix(matrix)
}
TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight,
TitleArtwork(item.poster, WolfskinTokens.Image.heroPosterRequestWidth, WolfskinTokens.Image.heroPosterRequestHeight,
Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }
.blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) }
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape))
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
}
Box(Modifier.fillMaxSize().background(horizontal))
Box(Modifier.fillMaxSize().background(vertical))
@ -131,13 +131,13 @@ fun TitleTileArtwork(item: TitleItem) {
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().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter)
val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2
TitleArtwork(item.poster, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter)
val height = D.episodeCardHeight - D.tileSheetInset * 2
val shape = remember { RoundedCornerShape(D.tileSheetRadius) }
val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) }
TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(),
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true)
} else TitleArtwork(item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize())
} else TitleArtwork(item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize())
}
}

View file

@ -36,6 +36,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
val item = state.data.item
val episode = when {
state.row == "episodes" -> state.data.episodes.getOrNull(state.column)
state.data.watchTarget.kind == "episode" -> state.data.watchTarget
else -> state.data.playTarget?.takeIf { it.kind == "episode" }
}
val type = if (item.kind == "movie") "Movie" else "Series"
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
@ -63,7 +68,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine)
}
}
Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))

View file

@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.design
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -90,6 +91,10 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
val list = shell.view.listState
var wheelPending by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() }
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
state.data.item.kind == "tv" && state.data.item.seasons > 0
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
}
@ -105,19 +110,19 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
if (state.data.related.isNotEmpty()) add("related")
}
}
val rowOffsets = remember(displayedRows, density) {
val rowOffsets = remember(displayedRows, density, headerHeight) {
FloatArray(displayedRows.size + 1).apply {
displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row).toPx() } }
displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row, headerHeight).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) {
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) {
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 = rowOffsets[index]
val height = with(density) { titleRowHeight(state.row).toPx() }
val height = with(density) { titleRowHeight(state.row, headerHeight).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)
@ -148,7 +153,11 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
val active = state.row == id && !shell.navFocused && state.sheet == null
when (id) {
"header" -> Box(Modifier.height(L.row_dhead)) { TitleHeader(state, active) }
"header" -> Box(Modifier.height(headerHeight)) {
TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size ->
measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap
} else Modifier)
}
"seasons" -> TitleSeasons(state, active)
"episodes" -> TitleMediaShelf(state, true, active)
"cast" -> TitleCast(state, active)
@ -156,16 +165,16 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
}
} }
item(key = "end-padding", contentType = "spacer") {
Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last())).coerceAtLeast(D.minimumEndPadding)))
Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last(), headerHeight)).coerceAtLeast(D.minimumEndPadding)))
}
}
}
}
private fun titleRowHeight(row: String): Dp = when (row) {
"header" -> L.row_dhead
private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) {
"header" -> headerHeight
"seasons" -> L.row_chips
"episodes" -> L.rowHeadingBand + WolfskinTokens.Card.tileH + L.rowGapAfter
"episodes" -> L.rowHeadingBand + D.episodeCardHeight + L.rowGapAfter
"cast" -> D.castHeight
else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter
}

View file

@ -22,7 +22,7 @@ internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
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)
queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight)
}
"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)

View file

@ -3,8 +3,6 @@ package org.justinvisser.wolfskin.design
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
@ -40,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import kotlin.math.roundToInt
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
@ -50,7 +49,9 @@ fun TitleCast(state: TitleState, active: Boolean) {
val track = rememberLazyListState()
val focusOwner = LocalTitleFocusOwner.current
val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() }
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) }
LaunchedEffect(state.column, active, state.data.cast.size) {
if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size)
}
Box(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, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2),
@ -81,11 +82,13 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
val titles = if (episodes) state.data.episodes else state.data.related
val id = if (episodes) "episodes" else "related"
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH
val track = rememberLazyListState()
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) }
LaunchedEffect(state.column, active, titles.size) {
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
}
Box(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, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
@ -114,7 +117,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } }
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
} else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
} else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
TitleTileArtwork(item)
@ -122,6 +125,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
val progress = item.progress
if (progress > 0f && progress < 1f) {
@ -155,15 +159,8 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
}
}
/** 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)
/** Apply the same lookahead and settled-offset rule to cast, episodes and related shelves. */
private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float, count: Int) {
val previous = firstVisibleItemIndex + (firstVisibleItemScrollOffset / pitch).roundToInt()
scrollShelfTo(shelfFirst(column, previous, slots, count), pitch)
}
private val titleTrackScrollSpec = tween<Float>(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))

View file

@ -48,12 +48,12 @@ fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height
.requiredSize(width + inset * 2, height + inset * 2)
.blur(if (hero) H.heroBlur else H.cardBlur)
.graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } }
SlotImage(poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true })
SlotImage(poster, if (hero) WolfskinTokens.Layout.heroSheetWidth else width, if (hero) WolfskinTokens.Layout.heroSheetHeight else height, washModifier, colorFilter = wash, onError = { failed.value = true })
val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset
val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f)
val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop)
SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true })
} else SlotImage(source, if (hero) WolfskinTokens.Card.posterW * 5 else width, if (hero) WolfskinTokens.Card.posterW * 5 * (9f / 16f) else height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true })
} else SlotImage(source, width, height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true })
} else if (!hero) MediaArtworkFallback(title, type == "movie", showTitle = !landscape)
else HomeAmbient(Modifier.fillMaxSize())
}

View file

@ -52,10 +52,9 @@ private fun PeoplePicker(state: ShellState) {
}
}
}
val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1)
val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2
val left = (S.stageWidth - L.gear) / 2
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarRing)
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing)
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
if (state.gearFocused) {

View file

@ -1,6 +1,7 @@
package org.justinvisser.wolfskin.design
import android.content.Context
import android.net.Uri
import coil3.ComponentRegistry
import coil3.ImageLoader
import coil3.disk.DiskCache
@ -10,6 +11,7 @@ import coil3.request.crossfade
import coil3.memory.MemoryCache
import coil3.request.ImageRequest
import coil3.request.allowRgb565
import coil3.size.Scale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
@ -61,7 +63,7 @@ object WolfskinImages {
}
fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest =
ImageRequest.Builder(context).data(source).size(width, height).allowRgb565(poster).build()
ImageRequest.Builder(context).data(sourceForSize(source, width, height)).size(width, height).scale(Scale.FIT).allowRgb565(poster).build()
fun prefetch(context: Context, row: HomeRow) {
val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 }
@ -73,4 +75,35 @@ object WolfskinImages {
if (source != null) loader(context).enqueue(request(context, source, width, height, poster))
}
}
private val jellyfinImagePath = Regex("/Items/[^/]+/Images/[^/]+(?:/\\d+)?", RegexOption.IGNORE_CASE)
private val tmdbSizePath = Regex("/t/p/(?:w\\d+|original)/")
private val dimensionParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight")
private fun sourceForSize(source: Any, width: Int, height: Int): Any {
if (source !is String) return source
val uri = Uri.parse(source)
if (uri.scheme != "https" && uri.scheme != "http") return source
if (uri.host == "image.tmdb.org") {
val size = if (height > width) when {
width <= 500 -> "w500"
width <= 780 -> "w780"
else -> "original"
} else when {
width <= 780 -> "w780"
width <= 1280 -> "w1280"
else -> "original"
}
return uri.buildUpon().path(uri.path?.replace(tmdbSizePath, "/t/p/$size/")).build().toString()
}
if (!jellyfinImagePath.matches(uri.path.orEmpty())) return source
val builder = uri.buildUpon().clearQuery()
for (name in uri.queryParameterNames) {
if (name.lowercase() !in dimensionParameters) {
uri.getQueryParameters(name).forEach { value -> builder.appendQueryParameter(name, value) }
}
}
return builder.appendQueryParameter("fillWidth", width.toString())
.appendQueryParameter("fillHeight", height.toString()).build().toString()
}
}

View file

@ -224,8 +224,12 @@ object WolfskinTokens {
const val tileRequestHeight = 234
const val wideRequestWidth = 560
const val wideRequestHeight = 315
const val heroRequestWidth = 1280
const val heroRequestHeight = 720
const val heroRequestWidth = 1920
const val heroRequestHeight = 1080
const val heroPosterRequestWidth = 420
const val heroPosterRequestHeight = 630
const val episodeRequestWidth = 400
const val episodeRequestHeight = 280
const val castRequestWidth = 200
const val castRequestHeight = 200
const val memoryCacheMB = 64.0f
@ -416,6 +420,8 @@ object WolfskinTokens {
const val sheetOpacity = 0.5f
}
object Title {
val episodeCardHeight = 140.0.dp
val seriesHeaderGap = 20.0.dp
val lineGap = 11.0.dp
val lineLift = -2.0.dp
val overviewGap = 17.0.dp
@ -609,6 +615,7 @@ object WolfskinTokens {
val posterProgressBottom = 6.0.dp
}
object Discover {
val shelfHeadingBand = 52.0.dp
val sheetPaddingX = 32.0.dp
val sheetPaddingY = 40.0.dp
val sheetGap = 9.0.dp