From cd9e8eea81fe34a0e302bf2696e32b0261055a71 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 30 Oct 2026 21:34:47 +0100 Subject: [PATCH] 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. --- .../java/io/narl/protonstream/ui/Common.kt | 29 ++++ .../io/narl/protonstream/ui/TitleScreen.kt | 109 ++++++++++++--- .../io/narl/protonstream/ui/StatusTest.kt | 39 ++++++ crates/pstr-android/src/lib.rs | 7 + crates/pstr-app/src/ui/title.rs | 129 +++++++++++++++--- crates/pstr-app/src/ui/widgets.rs | 27 ++++ crates/pstr-core/src/metadata.rs | 32 +++++ docs/USER_GUIDE.md | 9 +- ios/ProtonStream/UI/Common.swift | 19 +++ ios/ProtonStream/UI/TitleView.swift | 79 ++++++++--- ios/ProtonStreamTests/UiLogicTests.swift | 32 +++++ 11 files changed, 452 insertions(+), 59 deletions(-) create mode 100644 android/app/src/test/java/io/narl/protonstream/ui/StatusTest.kt diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt index 4c47fff..3d221fe 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -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]. diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index e592817..972889e 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -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? { 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, 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( diff --git a/android/app/src/test/java/io/narl/protonstream/ui/StatusTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/StatusTest.kt new file mode 100644 index 0000000..beb12b1 --- /dev/null +++ b/android/app/src/test/java/io/narl/protonstream/ui/StatusTest.kt @@ -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? = 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))) + } +} diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index 5a83156..96433a6 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -620,6 +620,10 @@ pub struct TitleRecord { /// `Dual audio` — in that order. Never a release group's name. #[uniffi(default = [])] pub quality: Vec, + /// `Airing`, `Finished`, `Upcoming`, `On hiatus` or `Cancelled`: the + /// shared `TitleDetails::status_label`. + #[uniffi(default = None)] + pub status: Option, } /// 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()), tags: details.map_or_else(Vec::new, |details| details.tags.clone()), 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_airing_at: None, franchise: Vec::new(), diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index fc66b18..f31d1f8 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -85,7 +85,27 @@ pub fn show( .enumerate() .map(|(index, label)| (index, label.as_str())) .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; 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 // from the line above as small outlined marks, the way a box // prints them. + // + // After where the title stands, which is the one fact here that + // changes week to week. 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.horizontal(|ui| { 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 { 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() { 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(rating) = found.rating { @@ -711,6 +727,37 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String { 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. 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); } + // 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 .on_hover_cursor(egui::CursorIcon::PointingHand) .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(), } } + +#[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 + ); + } +} diff --git a/crates/pstr-app/src/ui/widgets.rs b/crates/pstr-app/src/ui/widgets.rs index 8aebd50..759092a 100644 --- a/crates/pstr-app/src/ui/widgets.rs +++ b/crates/pstr-app/src/ui/widgets.rs @@ -409,6 +409,33 @@ pub fn spec_badge(ui: &mut egui::Ui, text: &str) -> egui::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. pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response { egui::Frame::new() diff --git a/crates/pstr-core/src/metadata.rs b/crates/pstr-core/src/metadata.rs index 454f9db..1750cd9 100644 --- a/crates/pstr-core/src/metadata.rs +++ b/crates/pstr-core/src/metadata.rs @@ -238,6 +238,20 @@ impl TitleDetails { 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`. pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> { let at = self.next_airing_at.filter(|at| *at > now)?; @@ -589,6 +603,24 @@ impl Default for MetadataConfig { mod tests { 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 { TitleMetadata { provider: ProviderId::AniList, diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 4af02b6..7075854 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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 at all. A match you pick is never replaced. - **More on AniList** (or TVmaze, or TMDB) opens the title's page there. -- Each season has **Mark season watched** and **Download season**. -- Each episode shows a tick when you have watched it. Right-click an episode and choose **Mark +- Each season has **Mark season watched** and **Download season**. A show with more than five + 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. - **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 diff --git a/ios/ProtonStream/UI/Common.swift b/ios/ProtonStream/UI/Common.swift index 7616a08..69b01f2 100644 --- a/ios/ProtonStream/UI/Common.swift +++ b/ios/ProtonStream/UI/Common.swift @@ -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. /// Android's `GenreChip`. struct GenreChip: View { diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index 9846307..5754302 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -223,19 +223,21 @@ struct TitleView: View { } Text(factLine) .textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 6) - // Who made it, and whether more is coming. + // Who made it. let makers: [String?] = [ title.studios.isEmpty ? nil : title.studios.prefix(2).joined(separator: ", "), title.directors.first.map { "dir. \($0)" }, - airingNote(title), ] let facts = makers.compactMap { $0 }.joined(separator: " · ") if !facts.isEmpty { Text(facts).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 2) } - // What the files are, rather than what the title is. - if !title.quality.isEmpty { + // Where the title stands, then what the files are rather than + // what the title is. + let status = statusText(title) + if !title.quality.isEmpty || status != nil { HStack(spacing: 4) { + if let status { StatusChip(text: status.text, live: status.live).padding(.trailing, 4) } ForEach(title.quality, id: \.self) { SpecBadge(text: $0) } } .padding(.top, 8) @@ -330,11 +332,34 @@ struct TitleView: View { private func seasonChips(season: SeasonRecord?, side: CGFloat) -> some View { HStack(spacing: 0) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - // By position: two seasons can share a label. - ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in - FilterChip(label: entry.label, selected: index == seasonIndex) { seasonIndex = index } + 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) { + HStack(spacing: 8) { + // By position: two seasons can share a label. + ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in + FilterChip(label: entry.label, selected: index == seasonIndex) { seasonIndex = index } + } } } } @@ -407,15 +432,20 @@ private struct FranchiseTile: View { } } -/// "episode 7 on Sat 11 Oct" while one is due, "airing" while it is airing -/// with no date yet, else nothing. -private func airingNote(_ title: TitleRecord) -> String? { - if let at = title.nextAiringAt, let episode = title.nextEpisode { - let format = DateFormatter() - format.setLocalizedDateFormatFromTemplate("EEE d MMM") - return "episode \(episode) on " + format.string(from: Date(timeIntervalSince1970: TimeInterval(at))) - } - return title.airing ? "airing" : nil +/// The most seasons picked from a row of chips; past it, a list. Android's +/// `SEASON_CHIPS`. +private let seasonChipsMax = 5 + +/// 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() + format.setLocalizedDateFormatFromTemplate("EEE d MMM") + return ("\(label) · episode \(next.episode) " + format.string(from: Date(timeIntervalSince1970: TimeInterval(next.at))), live) } /// 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) { 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)) VStack(alignment: .leading, spacing: 2) { // Seen episodes stay legible but stop competing with the diff --git a/ios/ProtonStreamTests/UiLogicTests.swift b/ios/ProtonStreamTests/UiLogicTests.swift index 20565bb..a76036d 100644 --- a/ios/ProtonStreamTests/UiLogicTests.swift +++ b/ios/ProtonStreamTests/UiLogicTests.swift @@ -299,3 +299,35 @@ final class ThemeTests: XCTestCase { 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") + } +}