mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(app,android,ios): status, season dropdown, watched ticks
A title page now says where the show stands as a chip beside the quality marks: Airing with the day the next episode comes out, Finished, Upcoming, On hiatus or Cancelled. The words come from TitleDetails::status_label, which the bridge hands the phones as TitleRecord.status; the airing note leaves the line of facts. A show with more than five seasons picks its season from a drop-down rather than a row of pills that ran off the page, and a watched episode's still carries a tick in its corner as well as the mark at the end of its row.
This commit is contained in:
1 parent
c026f81283
commit
cd9e8eea81
11 files changed
+452
-59
No files matched your search
@@ -8,6 +8,11 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
@@ -276,6 +281,30 @@ internal fun GenreChip(text: String) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a title stands — "Airing · episode 8 Sat 1 Nov", "Finished" — as a
|
||||
* [GenreChip] with a dot in front: the accent while more is coming, muted once
|
||||
* it is all out. The desktop's `widgets::status_chip`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StatusChip(text: String, live: Boolean, modifier: Modifier = Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(
|
||||
modifier
|
||||
.background(scheme.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.background(if (live) scheme.primary else scheme.onSurfaceVariant, CircleShape),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(text, style = MaterialTheme.typography.labelMedium, color = scheme.onSurface)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fact about the files rather than the title — 4K, HDR, Dual audio — as a
|
||||
* small outlined mark, the way a box prints them. Quieter than a [GenreChip].
|
||||
|
||||
@@ -95,6 +95,8 @@ import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.MatchRecord
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Replay
|
||||
import androidx.compose.material.icons.filled.DownloadDone
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
@@ -254,12 +256,11 @@ internal fun TitleScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
// Who made it, and whether more is coming — the desktop's
|
||||
// line of facts, on a line of its own so neither wraps.
|
||||
// Who made it — the desktop's line of facts, on a line of
|
||||
// its own so neither wraps.
|
||||
listOfNotNull(
|
||||
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "),
|
||||
title.directors.firstOrNull()?.let { "dir. $it" },
|
||||
airingNote(title),
|
||||
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
|
||||
Text(
|
||||
it,
|
||||
@@ -268,9 +269,18 @@ internal fun TitleScreen(
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
// What the files are, rather than what the title is.
|
||||
if (title.quality.isNotEmpty()) {
|
||||
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
// Where the title stands, then what the files are rather
|
||||
// than what the title is.
|
||||
val status = statusText(title)
|
||||
if (title.quality.isNotEmpty() || status != null) {
|
||||
Row(
|
||||
Modifier.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
status?.let { (text, live) ->
|
||||
StatusChip(text, live, Modifier.padding(end = 4.dp))
|
||||
}
|
||||
title.quality.forEach { SpecBadge(it) }
|
||||
}
|
||||
}
|
||||
@@ -394,15 +404,21 @@ internal fun TitleScreen(
|
||||
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// By position: two seasons can share a label, and a
|
||||
// repeated key is a crash in a lazy list.
|
||||
itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry ->
|
||||
FilterChip(
|
||||
selected = index == seasonIndex,
|
||||
onClick = { seasonIndex = index },
|
||||
label = { Text(entry.label) },
|
||||
)
|
||||
if (title.seasons.size > SEASON_CHIPS) {
|
||||
// A long-running show's row of chips is a scroll of
|
||||
// its own; past a handful it is a list to pick from.
|
||||
SeasonList(title.seasons, seasonIndex, { seasonIndex = it }, Modifier.weight(1f))
|
||||
} else {
|
||||
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// By position: two seasons can share a label, and a
|
||||
// repeated key is a crash in a lazy list.
|
||||
itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry ->
|
||||
FilterChip(
|
||||
selected = index == seasonIndex,
|
||||
onClick = { seasonIndex = index },
|
||||
label = { Text(entry.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
season?.let {
|
||||
@@ -567,17 +583,51 @@ private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit
|
||||
}
|
||||
|
||||
/**
|
||||
* "Episode 7 on Sat 11 Oct" while one is due, "Airing" while it is airing with
|
||||
* no date yet, else nothing.
|
||||
* What the status chip says — "Airing · episode 7 Sat 11 Oct", "Finished" —
|
||||
* and whether more is coming. The desktop's `status_text`.
|
||||
*/
|
||||
private fun airingNote(title: TitleRecord): String? {
|
||||
internal fun statusText(title: TitleRecord): Pair<String, Boolean>? {
|
||||
val at = title.nextAiringAt
|
||||
val episode = title.nextEpisode
|
||||
if (at != null && episode != null) {
|
||||
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
|
||||
return "episode $episode on " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM"))
|
||||
val next = at != null && episode != null
|
||||
val label = title.status ?: (if (next || title.airing) "Airing" else return null)
|
||||
val live = next || title.airing
|
||||
if (at == null || episode == null) return label to live
|
||||
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
|
||||
return "$label · episode $episode " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM")) to live
|
||||
}
|
||||
|
||||
/** The most seasons picked from a row of chips; past it, a list. The desktop's `SEASON_PILLS`. */
|
||||
private const val SEASON_CHIPS = 5
|
||||
|
||||
/** The season shown, as a button that opens the list of all of them. */
|
||||
@Composable
|
||||
private fun SeasonList(seasons: List<SeasonRecord>, picked: Int, onPick: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box(modifier) {
|
||||
FilterChip(
|
||||
selected = true,
|
||||
onClick = { open = true },
|
||||
label = { Text(seasons.getOrNull(picked)?.label.orEmpty()) },
|
||||
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
|
||||
)
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
seasons.forEachIndexed { index, season ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(season.label) },
|
||||
onClick = {
|
||||
open = false
|
||||
onPick(index)
|
||||
},
|
||||
trailingIcon = if (index == picked) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = "Shown") }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (title.airing) "airing" else null
|
||||
}
|
||||
|
||||
/** How wide the page's text and controls run on a window wider than a phone. */
|
||||
@@ -702,6 +752,21 @@ private fun EpisodeRow(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
// Seen, said on the picture as well as by the mark at the
|
||||
// row's end: a season is scanned by its stills.
|
||||
if (episode.watched) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(5.dp)
|
||||
.size(18.dp)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||
.padding(2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TitleType
|
||||
|
||||
class StatusTest {
|
||||
private fun title(status: String?, airing: Boolean = false, next: Pair<UInt, Long>? = null) = TitleRecord(
|
||||
key = "k", name = "k", year = null, kind = TitleType.SERIES, watchedCount = 0uL,
|
||||
episodeCount = 0uL, canonicalName = null, originalName = null, overview = null,
|
||||
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
|
||||
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
|
||||
manualMatch = false, seasons = emptyList(),
|
||||
displayName = "k", wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
|
||||
directors = emptyList(), tags = emptyList(), airing = airing, nextEpisode = next?.first,
|
||||
nextAiringAt = next?.second, franchise = emptyList(), continueIndex = null, status = status,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the status chip names the next episode while one is due`() {
|
||||
val (text, live) = statusText(title("Airing", airing = true, next = 8u to 1_800_000_000L))!!
|
||||
assertTrue(text, text.startsWith("Airing · episode 8 "))
|
||||
assertTrue(live)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished title is said plainly and is not live`() {
|
||||
assertEquals("Finished" to false, statusText(title("Finished")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title with no status and nothing airing has no chip`() {
|
||||
assertNull(statusText(title(null)))
|
||||
assertEquals("Airing" to true, statusText(title(null, airing = true)))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user