mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +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
@@ -620,6 +620,10 @@ pub struct TitleRecord {
|
||||
/// `Dual audio` — in that order. Never a release group's name.
|
||||
#[uniffi(default = [])]
|
||||
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.
|
||||
@@ -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(),
|
||||
|
||||
+112
-17
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user