Align TV layouts and close recovery gaps
This commit is contained in:
parent
bfe3dbbb16
commit
04b1bdd6aa
22 changed files with 522 additions and 53 deletions
|
|
@ -27,7 +27,8 @@ import androidx.compose.ui.graphics.RectangleShape
|
|||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.FirstBaseline
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
||||
|
|
@ -49,7 +50,7 @@ fun DiscoverOverlay(state: DiscoverState) {
|
|||
is DiscoverOverlayState.Hidden -> {
|
||||
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(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) }
|
||||
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) }
|
||||
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) }
|
||||
}
|
||||
}
|
||||
|
|
@ -59,7 +60,7 @@ fun DiscoverOverlay(state: DiscoverState) {
|
|||
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body),
|
||||
color = C.muted, style = DiscoverType.body)
|
||||
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) }
|
||||
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) }
|
||||
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null }
|
||||
}
|
||||
}
|
||||
|
|
@ -72,15 +73,10 @@ 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 = DiscoverType.sheetTitle)
|
||||
Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
|
||||
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 = DiscoverType.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())
|
||||
}
|
||||
})
|
||||
Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body)
|
||||
if (state.isSignedIn) SheetRatings(sheet.title.ratings)
|
||||
when (sheet.phase) {
|
||||
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
|
||||
RequestPhase.LoadingRecipients -> {
|
||||
|
|
@ -100,6 +96,28 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
|
|||
}
|
||||
}
|
||||
|
||||
/** The inline ratings baseline includes the first mark; the inherited text strut keeps its descent. */
|
||||
@Composable
|
||||
private fun SheetRatings(ratings: List<DiscoverRating>) {
|
||||
if (ratings.isEmpty()) return
|
||||
Layout(content = {
|
||||
DiscoverRatings(ratings)
|
||||
Text(" ", color = C.muted, style = DiscoverType.body)
|
||||
}) { children, constraints ->
|
||||
val row = children[0].measure(constraints.copy(minHeight = 0))
|
||||
val strut = children[1].measure(constraints.copy(minWidth = 0, minHeight = 0))
|
||||
val mark = DiscoverType.ratings.fontSize.toPx()
|
||||
val baseline = (row.height + mark) / 2f
|
||||
val descent = maxOf(row.height - baseline, (strut.height - strut[FirstBaseline]).toFloat())
|
||||
val height = maxOf(baseline, strut[FirstBaseline].toFloat()) + descent
|
||||
val top = D.sheetRatingTop.roundToPx()
|
||||
val bottom = D.sheetRatingBottom.roundToPx()
|
||||
layout(row.width, kotlin.math.round(height).toInt() + top + bottom) {
|
||||
row.placeRelative(0, top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
|
||||
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@ class DiscoverState(
|
|||
ShellKey.Ok -> when (titleButton) {
|
||||
0 -> if (requests[requireNotNull(title).key]?.any { it.requester == shell.person.key } == true) shell.requestsVisible = true else openRequest(false)
|
||||
1 -> openRequest(true)
|
||||
2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) }
|
||||
2 -> title?.let { if (it.key in hidden) restore(it, showToast = true) else hide(it) }
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
|
@ -328,13 +328,13 @@ class DiscoverState(
|
|||
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
|
||||
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
|
||||
ShellKey.Back -> overlay = null
|
||||
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else { overlay = null; title = null; onPop() }
|
||||
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = false) else { overlay = null; title = null; onPop() }
|
||||
}
|
||||
is DiscoverOverlayState.Restore -> when (key) {
|
||||
ShellKey.Left, ShellKey.Up -> sheet.focus = 0
|
||||
ShellKey.Right, ShellKey.Down -> sheet.focus = 1
|
||||
ShellKey.Back -> overlay = null
|
||||
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else overlay = null
|
||||
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = true) else overlay = null
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
|
|
@ -380,9 +380,9 @@ class DiscoverState(
|
|||
}
|
||||
}
|
||||
|
||||
fun restore(item: DiscoverTitle) {
|
||||
fun restore(item: DiscoverTitle, showToast: Boolean) {
|
||||
if (!canRequest) { refresh(); return }
|
||||
hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks."
|
||||
hidden.remove(item.key); revision++; overlay = null; toast = if (showToast) "Back in your picks." else null
|
||||
updateCollectionCards()
|
||||
val profile = shell.person.key
|
||||
val version = (dismissalVersions[item.key] ?: 0) + 1
|
||||
|
|
@ -412,7 +412,7 @@ class DiscoverState(
|
|||
val lanes = service?.let {
|
||||
serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() }
|
||||
} ?: projectDiscoverLanes(snapshot, heroes, filter, hidden)
|
||||
return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "Try again", emptyList(), tail = DiscoverTail.Retry) else lanes
|
||||
return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "More picks", emptyList(), tail = DiscoverTail.Retry) else lanes
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,23 +4,45 @@ package org.justinvisser.wolfskin.design
|
|||
class HomeRatingCache(private val clock: () -> Long = System::currentTimeMillis) {
|
||||
private data class Entry(val ratings: List<HomeRating>, val readAt: Long)
|
||||
private val entries = linkedMapOf<String, Entry>()
|
||||
private val seasons = linkedMapOf<String, MutableMap<Int, Entry>>()
|
||||
|
||||
fun read(title: HomeTitle): List<HomeRating>? {
|
||||
val key = key(title)
|
||||
val entry = entries[key] ?: return null
|
||||
if (clock() - entry.readAt >= lifetimeMillis) { entries.remove(key); return null }
|
||||
val season = seasonKey(title)
|
||||
val bucket = season?.let { seasons[it] }
|
||||
val entry = if (season != null) bucket?.get(title.episode) else entries[key(title)]
|
||||
if (entry == null) return null
|
||||
if (clock() - entry.readAt >= lifetimeMillis) {
|
||||
if (season == null) entries.remove(key(title)) else {
|
||||
bucket?.remove(title.episode)
|
||||
if (bucket.isNullOrEmpty()) seasons.remove(season)
|
||||
}
|
||||
return null
|
||||
}
|
||||
return entry.ratings
|
||||
}
|
||||
|
||||
fun save(title: HomeTitle, ratings: List<HomeRating>) {
|
||||
val key = key(title)
|
||||
entries.remove(key)
|
||||
entries[key] = Entry(ratings, clock())
|
||||
while (entries.size > capacity) entries.remove(entries.keys.first())
|
||||
val entry = Entry(ratings, clock())
|
||||
val season = seasonKey(title)
|
||||
if (season != null) {
|
||||
val bucket = seasons.remove(season) ?: mutableMapOf()
|
||||
bucket[requireNotNull(title.episode)] = entry
|
||||
seasons[season] = bucket
|
||||
while (seasons.size > capacity) seasons.remove(seasons.keys.first())
|
||||
} else {
|
||||
val key = key(title)
|
||||
entries.remove(key)
|
||||
entries[key] = entry
|
||||
while (entries.size > capacity) entries.remove(entries.keys.first())
|
||||
}
|
||||
}
|
||||
|
||||
private fun key(title: HomeTitle): String = if (title.isEpisode) "episode:${title.id}" else "${title.type}:${title.tmdbId ?: title.id}"
|
||||
|
||||
private fun seasonKey(title: HomeTitle): String? =
|
||||
if (title.isEpisode && title.tmdbId != null && title.season != null && title.episode != null)
|
||||
"${title.tmdbId}:${title.season}" else null
|
||||
|
||||
companion object {
|
||||
private const val capacity = 300
|
||||
private val lifetimeMillis = (WolfskinTokens.Behaviour.cacheRatingsHours * 60 * 60 * 1000).toLong()
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ fun LibraryScreen(
|
|||
}
|
||||
LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt }
|
||||
LaunchedEffect(grid.status) {
|
||||
if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab)
|
||||
if (grid.status == LibraryStatus.Loading && !shell.navFocused) 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
|
||||
|
|
@ -128,7 +128,10 @@ fun LibraryScreen(
|
|||
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 ->
|
||||
items((items.size + grid.columns - 1) / grid.columns, key = { row ->
|
||||
val title = items[row * grid.columns]
|
||||
"grid-${if (grid.catalogue) title.catalogueKey else title.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) {
|
||||
|
|
@ -174,7 +177,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
|
|||
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(title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
Text(title, modifier = Modifier.alignByBaseline().exactLineBox(WolfskinTokens.Type.screenTitle), color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle)
|
||||
savedAt?.let { SavedPill(it) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,8 +98,8 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
|
|||
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
||||
Column(Modifier.weight(1f)) {
|
||||
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
|
||||
Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
|
||||
Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
|
||||
Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (group.wolfskin) SettingsPill("Wolfskin", focused)
|
||||
SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue