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:
nvrl committed 2026-10-30 21:34:47 +01:00
1 parent c026f81283
commit cd9e8eea81
11 files changed
+435 -42

No files matched your search

@@ -8,6 +8,11 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.Box 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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope 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 * 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]. * 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 uniffi.pstr_android.MatchRecord
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.FilterChip 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.Replay
import androidx.compose.material.icons.filled.DownloadDone import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
@@ -254,12 +256,11 @@ internal fun TitleScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp), modifier = Modifier.padding(top = 6.dp),
) )
// Who made it, and whether more is coming — the desktop's // Who made it — the desktop's line of facts, on a line of
// line of facts, on a line of its own so neither wraps. // its own so neither wraps.
listOfNotNull( listOfNotNull(
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "), title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "),
title.directors.firstOrNull()?.let { "dir. $it" }, title.directors.firstOrNull()?.let { "dir. $it" },
airingNote(title),
).joinToString(" · ").takeIf(String::isNotEmpty)?.let { ).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
Text( Text(
it, it,
@@ -268,9 +269,18 @@ internal fun TitleScreen(
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = 2.dp),
) )
} }
// What the files are, rather than what the title is. // Where the title stands, then what the files are rather
if (title.quality.isNotEmpty()) { // than what the title is.
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { 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) } title.quality.forEach { SpecBadge(it) }
} }
} }
@@ -394,6 +404,11 @@ internal fun TitleScreen(
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp), Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
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)) { LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// By position: two seasons can share a label, and a // By position: two seasons can share a label, and a
// repeated key is a crash in a lazy list. // repeated key is a crash in a lazy list.
@@ -405,6 +420,7 @@ internal fun TitleScreen(
) )
} }
} }
}
season?.let { season?.let {
// For someone joining the show partway, who would // For someone joining the show partway, who would
// otherwise tick every earlier episode one by one. // otherwise tick every earlier episode one by one.
@@ -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 * What the status chip says — "Airing · episode 7 Sat 11 Oct", "Finished" —
* no date yet, else nothing. * 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 at = title.nextAiringAt
val episode = title.nextEpisode val episode = title.nextEpisode
if (at != null && episode != null) { 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()) 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")) 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. */ /** 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), 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)) { Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text( 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)))
}
}
+7
View File
@@ -620,6 +620,10 @@ pub struct TitleRecord {
/// `Dual audio` — in that order. Never a release group's name. /// `Dual audio` — in that order. Never a release group's name.
#[uniffi(default = [])] #[uniffi(default = [])]
pub quality: Vec<String>, pub quality: Vec<String>,
/// `Airing`, `Finished`, `Upcoming`, `On hiatus` or `Cancelled`: the
/// shared `TitleDetails::status_label`.
#[uniffi(default = None)]
pub status: Option<String>,
} }
/// Playback preferences that outlive one file, one title and one launch. /// Playback preferences that outlive one file, one title and one launch.
@@ -3684,6 +3688,9 @@ fn title_record(
directors: details.map_or_else(Vec::new, |details| details.directors.clone()), directors: details.map_or_else(Vec::new, |details| details.directors.clone()),
tags: details.map_or_else(Vec::new, |details| details.tags.clone()), tags: details.map_or_else(Vec::new, |details| details.tags.clone()),
airing: details.is_some_and(|details| details.is_airing()), airing: details.is_some_and(|details| details.is_airing()),
status: details
.and_then(|details| details.status_label())
.map(str::to_owned),
next_episode: None, next_episode: None,
next_airing_at: None, next_airing_at: None,
franchise: Vec::new(), franchise: Vec::new(),
+112 -17
View File
@@ -85,7 +85,27 @@ pub fn show(
.enumerate() .enumerate()
.map(|(index, label)| (index, label.as_str())) .map(|(index, label)| (index, label.as_str()))
.collect(); .collect();
if let Some(index) = ui::widgets::segmented(ui, picked, &choices) { // A long-running show's row of pills ran off the page;
// past a handful it is a list to pick from instead.
let chosen = if choices.len() > SEASON_PILLS {
egui::ComboBox::from_id_salt(("season-list", &title.key))
.selected_text(labels[picked].as_str())
.height(360.0)
.show_ui(ui, |ui| {
let mut chosen = None;
for &(index, label) in &choices {
if ui.selectable_label(index == picked, label).clicked() {
chosen = Some(index);
}
}
chosen
})
.inner
.flatten()
} else {
ui::widgets::segmented(ui, picked, &choices)
};
if let Some(index) = chosen {
picked = index; picked = index;
ui.data_mut(|data| data.insert_temp(picked_id, index)); ui.data_mut(|data| data.insert_temp(picked_id, index));
} }
@@ -348,11 +368,22 @@ fn header(
// What the files are, rather than what the title is: set apart // What the files are, rather than what the title is: set apart
// from the line above as small outlined marks, the way a box // from the line above as small outlined marks, the way a box
// prints them. // prints them.
//
// After where the title stands, which is the one fact here that
// changes week to week.
let quality = title.quality().badges(); let quality = title.quality().badges();
if !quality.is_empty() { let status = found
.as_ref()
.and_then(|found| found.details.as_ref())
.and_then(|details| status_text(details, now()));
if !quality.is_empty() || status.is_some() {
ui.add_space(theme::space::XS); ui.add_space(theme::space::XS);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::space::XS; ui.spacing_mut().item_spacing.x = theme::space::XS;
if let Some((text, live)) = &status {
ui::widgets::status_chip(ui, text, *live);
ui.add_space(theme::space::XS);
}
for badge in quality { for badge in quality {
ui::widgets::spec_badge(ui, badge); ui::widgets::spec_badge(ui, badge);
} }
@@ -678,21 +709,6 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
if let Some(director) = details.directors.first() { if let Some(director) = details.directors.first() {
parts.push(format!("dir. {director}")); parts.push(format!("dir. {director}"));
} }
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64);
if let Some((episode, at)) = details.next_airing(now) {
let when = chrono::DateTime::from_timestamp(at, 0)
.map(|at| {
at.with_timezone(&chrono::Local)
.format("%a %-d %b, %H:%M")
.to_string()
})
.unwrap_or_default();
parts.push(format!("episode {episode} airs {when}"));
} else if details.is_airing() {
parts.push("airing".to_owned());
}
} }
if let Some(found) = found { if let Some(found) = found {
if let Some(rating) = found.rating { if let Some(rating) = found.rating {
@@ -711,6 +727,37 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
parts.join(" · ") parts.join(" · ")
} }
/// The most seasons picked from a row of pills; past it, a list.
const SEASON_PILLS: usize = 5;
/// Unix seconds, now.
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64)
}
/// What the status chip says — `Airing · episode 8 Sat 1 Nov, 14:00`,
/// `Finished` — and whether more is coming.
fn status_text(details: &pstr_core::metadata::TitleDetails, now: i64) -> Option<(String, bool)> {
let next = details.next_airing(now);
let label = details.status_label().or(next.map(|_| "Airing"))?;
let live = next.is_some() || details.is_airing();
Some(match next {
Some((episode, at)) => {
let when = chrono::DateTime::from_timestamp(at, 0)
.map(|at| {
at.with_timezone(&chrono::Local)
.format("%a %-d %b, %H:%M")
.to_string()
})
.unwrap_or_default();
(format!("{label} · episode {episode} {when}"), live)
}
None => (label.to_owned(), live),
})
}
/// How big an episode's still is drawn. /// How big an episode's still is drawn.
const STILL: egui::Vec2 = egui::vec2(160.0, 90.0); const STILL: egui::Vec2 = egui::vec2(160.0, 90.0);
@@ -1000,6 +1047,21 @@ pub(crate) fn still(
theme::accent_fill(&painter, played, radius, 0.0); theme::accent_fill(&painter, played, radius, 0.0);
} }
// Seen, said on the picture as well as by the mark at the row's end: a
// season is scanned by its stills, and a dimmed one alone read as one that
// had not loaded.
if watched {
let corner = rect.right_top() + egui::vec2(-14.0, 14.0);
painter.circle_filled(corner, 9.0, theme::accent());
painter.text(
corner,
egui::Align2::CENTER_CENTER,
egui_phosphor::regular::CHECK,
theme::Role::Micro.font(),
theme::on_accent(),
);
}
if response if response
.on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(if episode.resume_at().is_some() { .on_hover_text(if episode.resume_at().is_some() {
@@ -1083,3 +1145,36 @@ fn key_of(episode: &Episode) -> DownloadKey {
link_id: episode.node.link_id.clone(), link_id: episode.node.link_id.clone(),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn details(status: &str, next: Option<(u32, i64)>) -> pstr_core::metadata::TitleDetails {
pstr_core::metadata::TitleDetails {
status: Some(status.to_owned()),
next_episode: next.map(|(episode, _)| episode),
next_airing_at: next.map(|(_, at)| at),
..Default::default()
}
}
#[test]
fn the_status_chip_says_when_the_next_episode_airs_and_only_while_it_is_ahead() {
let (text, live) = status_text(&details("RELEASING", Some((8, 2_000))), 1_000).unwrap();
assert!(text.starts_with("Airing · episode 8 "), "{text}");
assert!(live);
// Fetched a while ago: the date has passed, so it says nothing of it.
let (text, live) = status_text(&details("RELEASING", Some((8, 500))), 1_000).unwrap();
assert_eq!(text, "Airing");
assert!(live);
assert_eq!(
status_text(&details("FINISHED", None), 1_000),
Some(("Finished".to_owned(), false))
);
assert_eq!(
status_text(&pstr_core::metadata::TitleDetails::default(), 0),
None
);
}
}
+27
View File
@@ -409,6 +409,33 @@ pub fn spec_badge(ui: &mut egui::Ui, text: &str) -> egui::Response {
.response .response
} }
/// Where a title stands — `Airing · episode 8 Sat 1 Nov, 14:00`, `Finished` —
/// as a [`chip`] with a dot in front: the accent while more is coming, muted
/// once it is all out.
pub fn status_chip(ui: &mut egui::Ui, text: &str, live: bool) -> egui::Response {
egui::Frame::new()
.fill(theme::card_hover().gamma_multiply(0.8))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::symmetric(9, 3))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::space::XS;
let (dot, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover());
ui.painter().circle_filled(
dot.center(),
3.0,
if live {
theme::accent()
} else {
theme::muted()
},
);
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
});
})
.response
}
/// A small rounded label: a genre, a year, a fact. /// A small rounded label: a genre, a year, a fact.
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response { pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
egui::Frame::new() egui::Frame::new()
+32
View File
@@ -238,6 +238,20 @@ impl TitleDetails {
self.status.as_deref() == Some("RELEASING") self.status.as_deref() == Some("RELEASING")
} }
/// [`TitleDetails::status`] said for a person: `Airing`, `Finished`,
/// `Upcoming`, `On hiatus` or `Cancelled`. `None` for a status nobody
/// filled in, or one this build does not know.
pub fn status_label(&self) -> Option<&'static str> {
Some(match self.status.as_deref()? {
"RELEASING" => "Airing",
"FINISHED" => "Finished",
"NOT_YET_RELEASED" => "Upcoming",
"HIATUS" => "On hiatus",
"CANCELLED" => "Cancelled",
_ => return None,
})
}
/// The next episode and when it airs, when that is still after `now`. /// The next episode and when it airs, when that is still after `now`.
pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> { pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> {
let at = self.next_airing_at.filter(|at| *at > now)?; let at = self.next_airing_at.filter(|at| *at > now)?;
@@ -589,6 +603,24 @@ impl Default for MetadataConfig {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_status_is_said_for_a_person_and_an_unknown_one_is_not_said_at_all() {
let status = |value: &str| {
TitleDetails {
status: Some(value.to_owned()),
..TitleDetails::default()
}
.status_label()
};
assert_eq!(status("RELEASING"), Some("Airing"));
assert_eq!(status("FINISHED"), Some("Finished"));
assert_eq!(status("NOT_YET_RELEASED"), Some("Upcoming"));
assert_eq!(status("HIATUS"), Some("On hiatus"));
assert_eq!(status("CANCELLED"), Some("Cancelled"));
assert_eq!(status("SOMETHING_NEW"), None);
assert_eq!(TitleDetails::default().status_label(), None);
}
fn metadata() -> TitleMetadata { fn metadata() -> TitleMetadata {
TitleMetadata { TitleMetadata {
provider: ProviderId::AniList, provider: ProviderId::AniList,
+7 -2
View File
@@ -78,8 +78,13 @@ top.
is. Each result names the site it came from. Use it when the app guessed wrong or did not guess is. Each result names the site it came from. Use it when the app guessed wrong or did not guess
at all. A match you pick is never replaced. at all. A match you pick is never replaced.
- **More on AniList** (or TVmaze, or TMDB) opens the title's page there. - **More on AniList** (or TVmaze, or TMDB) opens the title's page there.
- Each season has **Mark season watched** and **Download season**. - Each season has **Mark season watched** and **Download season**. A show with more than five
- Each episode shows a tick when you have watched it. Right-click an episode and choose **Mark seasons lists them in a drop-down instead of a row of buttons.
- With posters and descriptions on, a chip under the name says where the show stands: **Airing**
with the day the next episode comes out, **Finished**, **Upcoming**, **On hiatus** or
**Cancelled**.
- Each episode shows a tick when you have watched it, at the end of its row and in the corner of
its picture. Right-click an episode and choose **Mark
watched up to here** to tick it and every episode before it. watched up to here** to tick it and every episode before it.
- **In this franchise** lists the other parts of the series, when posters and descriptions are on. - **In this franchise** lists the other parts of the series, when posters and descriptions are on.
- With posters and descriptions on, a show's logo takes the place of its name at the top of the - With posters and descriptions on, a show's logo takes the place of its name at the top of the
+19
View File
@@ -401,6 +401,25 @@ private extension UIColor {
} }
} }
/// 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. Android's `StatusChip`.
struct StatusChip: View {
@Environment(\.scheme) private var scheme
let text: String
let live: Bool
var body: some View {
HStack(spacing: 6) {
Circle().fill(live ? scheme.primary : scheme.onSurfaceVariant).frame(width: 6, height: 6)
Text(text).textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(scheme.surfaceVariant, in: RoundedRectangle(cornerRadius: 12))
}
}
/// Something the title is filed under — a genre — as a small rounded label. /// Something the title is filed under — a genre — as a small rounded label.
/// Android's `GenreChip`. /// Android's `GenreChip`.
struct GenreChip: View { struct GenreChip: View {
+54 -11
View File
@@ -223,19 +223,21 @@ struct TitleView: View {
} }
Text(factLine) Text(factLine)
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 6) .textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 6)
// Who made it, and whether more is coming. // Who made it.
let makers: [String?] = [ let makers: [String?] = [
title.studios.isEmpty ? nil : title.studios.prefix(2).joined(separator: ", "), title.studios.isEmpty ? nil : title.studios.prefix(2).joined(separator: ", "),
title.directors.first.map { "dir. \($0)" }, title.directors.first.map { "dir. \($0)" },
airingNote(title),
] ]
let facts = makers.compactMap { $0 }.joined(separator: " · ") let facts = makers.compactMap { $0 }.joined(separator: " · ")
if !facts.isEmpty { if !facts.isEmpty {
Text(facts).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 2) Text(facts).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 2)
} }
// What the files are, rather than what the title is. // Where the title stands, then what the files are rather than
if !title.quality.isEmpty { // what the title is.
let status = statusText(title)
if !title.quality.isEmpty || status != nil {
HStack(spacing: 4) { HStack(spacing: 4) {
if let status { StatusChip(text: status.text, live: status.live).padding(.trailing, 4) }
ForEach(title.quality, id: \.self) { SpecBadge(text: $0) } ForEach(title.quality, id: \.self) { SpecBadge(text: $0) }
} }
.padding(.top, 8) .padding(.top, 8)
@@ -330,6 +332,28 @@ struct TitleView: View {
private func seasonChips(season: SeasonRecord?, side: CGFloat) -> some View { private func seasonChips(season: SeasonRecord?, side: CGFloat) -> some View {
HStack(spacing: 0) { HStack(spacing: 0) {
if title.seasons.count > seasonChipsMax {
// A long-running show's row of chips is a scroll of its own;
// past a handful it is a list to pick from.
Menu {
Picker("Season", selection: $seasonIndex) {
ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in
Text(entry.label).tag(index)
}
}
} label: {
HStack(spacing: 4) {
Text(title.seasons.indices.contains(seasonIndex) ? title.seasons[seasonIndex].label : "")
Image(systemName: "chevron.down").font(.system(size: 12, weight: .semibold))
}
.textStyle(.labelLarge)
.foregroundStyle(scheme.onSecondaryContainer)
.padding(.horizontal, 12)
.frame(height: 32)
.background(scheme.secondaryContainer, in: RoundedRectangle(cornerRadius: Corner.standard))
}
Spacer(minLength: 0)
} else {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { HStack(spacing: 8) {
// By position: two seasons can share a label. // By position: two seasons can share a label.
@@ -338,6 +362,7 @@ struct TitleView: View {
} }
} }
} }
}
if let season { if let season {
// For someone joining the show partway, who would otherwise // For someone joining the show partway, who would otherwise
// tick every earlier episode one by one. // tick every earlier episode one by one.
@@ -407,15 +432,20 @@ private struct FranchiseTile: View {
} }
} }
/// "episode 7 on Sat 11 Oct" while one is due, "airing" while it is airing /// The most seasons picked from a row of chips; past it, a list. Android's
/// with no date yet, else nothing. /// `SEASON_CHIPS`.
private func airingNote(_ title: TitleRecord) -> String? { private let seasonChipsMax = 5
if let at = title.nextAiringAt, let episode = title.nextEpisode {
/// What the status chip says — "Airing · episode 7 Sat 11 Oct", "Finished" —
/// and whether more is coming. Android's `statusText`.
func statusText(_ title: TitleRecord) -> (text: String, live: Bool)? {
let next = title.nextAiringAt.flatMap { at in title.nextEpisode.map { (episode: $0, at: at) } }
guard let label = title.status ?? (next != nil || title.airing ? "Airing" : nil) else { return nil }
let live = next != nil || title.airing
guard let next else { return (label, live) }
let format = DateFormatter() let format = DateFormatter()
format.setLocalizedDateFormatFromTemplate("EEE d MMM") format.setLocalizedDateFormatFromTemplate("EEE d MMM")
return "episode \(episode) on " + format.string(from: Date(timeIntervalSince1970: TimeInterval(at))) return ("\(label) · episode \(next.episode) " + format.string(from: Date(timeIntervalSince1970: TimeInterval(next.at))), live)
}
return title.airing ? "airing" : nil
} }
/// One of the title's secondary actions: an icon with its name under it. /// One of the title's secondary actions: an icon with its name under it.
@@ -508,6 +538,19 @@ private struct EpisodeRow: View {
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
if let progress = episode.progress, !episode.watched { AccentProgress(progress: progress) } if let progress = episode.progress, !episode.watched { AccentProgress(progress: progress) }
} }
// Seen, said on the picture as well as by the mark at the
// row's end: a season is scanned by its stills.
.overlay(alignment: .topTrailing) {
if episode.watched {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(scheme.onPrimary)
.frame(width: 18, height: 18)
.background(scheme.primary, in: Circle())
.padding(5)
.accessibilityHidden(true)
}
}
.clipShape(RoundedRectangle(cornerRadius: Corner.standard)) .clipShape(RoundedRectangle(cornerRadius: Corner.standard))
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
// Seen episodes stay legible but stop competing with the // Seen episodes stay legible but stop competing with the
+32
View File
@@ -299,3 +299,35 @@ final class ThemeTests: XCTestCase {
XCTAssertEqual(formatRuntime(124), "2 h 4 min") XCTAssertEqual(formatRuntime(124), "2 h 4 min")
} }
} }
final class StatusTextTests: XCTestCase {
private func title(status: String?, airing: Bool = false, next: (UInt32, Int64)? = nil) -> TitleRecord {
TitleRecord(
key: "k", name: "k", year: nil, kind: .series, watchedCount: 0,
episodeCount: 0, canonicalName: nil, originalName: nil, overview: nil,
metadataProvider: nil, metadataId: nil, metadataYear: nil, metadataKind: nil, posterUrl: nil,
backdropUrl: nil, rating: nil, genres: [], providerEpisodeCount: nil, externalUrl: nil,
manualMatch: false, seasons: [],
displayName: "k", wideUrl: nil, formatLabel: nil, seasonLabel: nil, studios: [],
directors: [], tags: [], airing: airing, nextEpisode: next?.0, nextAiringAt: next?.1,
franchise: [], continueIndex: nil, status: status
)
}
func testTheStatusChipNamesTheNextEpisodeWhileOneIsDue() throws {
let status = try XCTUnwrap(statusText(title(status: "Airing", airing: true, next: (8, 1_800_000_000))))
XCTAssertTrue(status.text.hasPrefix("Airing · episode 8 "), status.text)
XCTAssertTrue(status.live)
}
func testAFinishedTitleIsSaidPlainlyAndIsNotLive() throws {
let status = try XCTUnwrap(statusText(title(status: "Finished")))
XCTAssertEqual(status.text, "Finished")
XCTAssertFalse(status.live)
}
func testATitleWithNoStatusAndNothingAiringHasNoChip() {
XCTAssertNil(statusText(title(status: nil)))
XCTAssertEqual(statusText(title(status: nil, airing: true))?.text, "Airing")
}
}