Merge Libraries and reuse the shared catalogue surface
This commit is contained in:
commit
c1559e2513
634 changed files with 2167 additions and 276 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
})
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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++ }
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
wolfskin-design/src/main/res/drawable/wolfskin_eye.xml
Normal file
1
wolfskin-design/src/main/res/drawable/wolfskin_eye.xml
Normal 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>
|
||||
1
wolfskin-design/src/main/res/drawable/wolfskin_film.xml
Normal file
1
wolfskin-design/src/main/res/drawable/wolfskin_film.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue