Files
proton-stream/crates/pstr-app/src/ui/title.rs
T
nvrl cd9e8eea81 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.
2026-10-30 21:34:47 +01:00

1181 lines
46 KiB
Rust

//! One title: the still, what it is, and every episode under it.
use pstr_core::library::{Episode, Library, Season, Title, TitleKind};
use pstr_core::metadata::TitleMetadata;
use crate::app::{Action, Page};
use crate::engine::{DownloadItem, DownloadKey, DownloadState};
use crate::playback::PlaybackTarget;
use crate::theme;
use crate::ui::widgets::Named;
use crate::ui::{self, Art, Card};
pub struct OfflineView<'a> {
pub downloads: &'a [DownloadItem],
pub files: &'a std::collections::HashSet<DownloadKey>,
}
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
key: &str,
franchise: Option<&[String]>,
offline: OfflineView<'_>,
actions: &mut Vec<Action>,
) {
let Some(title) = library.get(key) else {
// The catalog was replaced under the page — a recrawl that dropped this
// title. Nothing to show, so go back rather than draw an empty shell.
actions.push(Action::Goto(Page::Library));
return;
};
if back_link(ui).clicked() {
actions.push(Action::Goto(Page::Library));
}
ui.add_space(theme::space::S);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
header(ui, art, title, &offline, actions);
ui.add_space(theme::space::XL);
if let Some(keys) = franchise {
let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
if members.len() > 1 {
ui::section(ui, "In this franchise");
franchise_row(ui, art, title, &members, actions);
ui.add_space(theme::space::XL);
}
}
// One season at a time, picked from a row of pills, rather than
// every season stacked as a collapsing header: a four-season show
// was a page of headers to scroll past to reach the one being
// watched. The one opened first is the one with the next episode.
let picked_id = ui.id().with(("season", &title.key));
let default = title
.next_up()
.and_then(|next| {
title.seasons.iter().position(|season| {
season
.episodes
.iter()
.any(|episode| episode.node.link_id == next.node.link_id)
})
})
.unwrap_or(0);
let mut picked = ui
.data(|data| data.get_temp::<usize>(picked_id))
.unwrap_or(default)
.min(title.seasons.len().saturating_sub(1));
let Some(season) = title.seasons.get(picked) else {
return;
};
ui.horizontal(|ui| {
if title.seasons.len() > 1 {
let labels: Vec<String> =
title.seasons.iter().map(|season| season.label()).collect();
let choices: Vec<(usize, &str)> = labels
.iter()
.enumerate()
.map(|(index, label)| (index, label.as_str()))
.collect();
// 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));
}
ui.add_space(theme::space::L);
}
ui.label(ui::muted(ui::library::plural(
season.episodes.len(),
"episode",
)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
season_download(ui, title, season, &offline, actions);
season_watched(ui, title, season, actions);
});
});
ui.add_space(theme::space::L);
let Some(season) = title.seasons.get(picked) else {
return;
};
for episode in &season.episodes {
episode_row(ui, art, title, season, episode, &offline, actions);
}
ui.add_space(theme::space::L);
});
}
/// Every title of the franchise, in release order, as covers — this one
/// marked rather than left out, so the row says where it sits in the story.
fn franchise_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
current: &Title,
members: &[&Title],
actions: &mut Vec<Action>,
) {
ui::shelf(ui, ("franchise", &current.key), |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for member in members {
let here = member.key == current.key;
let picture = art.poster(member);
let found = art
.metadata
.get(&member.key)
.and_then(|record| record.metadata.as_ref());
let what = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.format_label())
.unwrap_or(match member.kind {
TitleKind::Series => "Series",
TitleKind::Film => "Film",
});
let year = found
.and_then(|found| found.year)
.or(member.year)
.map(|year| format!(" · {year}"))
.unwrap_or_default();
let response = ui::card(
ui,
Card {
art: picture,
name: art.name(member),
subtitle: format!("{what}{year}"),
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: here.then(|| "Here".to_owned()),
width: 128.0,
aspect: theme::POSTER_ASPECT,
},
);
if !here && response.clicked() {
actions.push(Action::Goto(Page::Title(member.key.clone())));
}
}
});
});
}
/// A title's poster, at the poster's own shape, with how far into it the
/// viewer is along its foot.
fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) {
let size = egui::vec2(180.0, 270.0);
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
let radius = egui::CornerRadius::same(theme::radius::LG);
ui.painter()
.add(theme::tile_shadow(1.0).as_shape(rect, radius));
ui.painter().add(
egui::epaint::RectShape::filled(rect, radius, egui::Color32::WHITE)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), size)),
);
if let Some(progress) = progress {
let track = egui::Rect::from_min_max(
egui::pos2(rect.left(), rect.bottom() - 4.0),
rect.right_bottom(),
);
let painter = ui.painter().with_clip_rect(track);
painter.rect_filled(rect, radius, egui::Color32::from_black_alpha(150));
let mut played = rect;
played.set_width(rect.width() * progress.clamp(0.0, 1.0) as f32);
theme::accent_fill(&painter, played, radius, 0.0);
}
}
/// "← Library", as a link rather than a button: it is navigation, and a
/// filled button beside the page's real actions competed with them.
fn back_link(ui: &mut egui::Ui) -> egui::Response {
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT));
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
if response.hovered() {
ui.painter().hline(
response.rect.x_range(),
response.rect.bottom(),
egui::Stroke::new(1.0, theme::muted()),
);
}
response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Back (Esc, Alt ←)")
}
/// Download, or delete, a whole season.
/// Mark a whole season seen, or unseen once it all is — for someone who
/// watched it elsewhere and joins the show here.
fn season_watched(ui: &mut egui::Ui, title: &Title, season: &Season, actions: &mut Vec<Action>) {
let all = season.episodes.iter().all(|episode| episode.is_watched());
let label = if all {
"Mark season unwatched"
} else {
"Mark season watched"
};
if ui::tonal_button(ui, label).clicked() {
actions.push(Action::MarkEpisodes {
key: title.key.clone(),
links: season
.episodes
.iter()
.map(|e| (e.node.share_id.clone(), e.node.link_id.clone()))
.collect(),
watched: !all,
what: format!("{} {}", title.name, season.label()),
});
}
}
fn season_download(
ui: &mut egui::Ui,
title: &Title,
season: &Season,
offline: &OfflineView<'_>,
actions: &mut Vec<Action>,
) {
let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect();
let all_offline = season_keys.iter().all(|key| offline.files.contains(key));
if all_offline {
if ui
.button("Delete season")
.on_hover_text("Delete the offline copies; keep the online source")
.clicked()
{
actions.push(Action::DeleteOffline(
season_keys,
format!("{} {}", title.name, season.label()),
));
}
} else if ui
.button("Download season")
.on_hover_text("Download every episode in this season")
.clicked()
{
actions.push(Action::MakeOffline(
season
.episodes
.iter()
.map(|episode| PlaybackTarget::new(title, episode))
.collect(),
));
}
}
/// The still, the name and the one button that matters.
fn header(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
offline: &OfflineView<'_>,
actions: &mut Vec<Action>,
) {
// Cloned out of the borrow: `art` is held mutably for the card below, and
// the description is drawn beside it.
let record = art.metadata.get(&title.key);
let found = record.and_then(|record| record.metadata.clone());
let by_hand = record.is_some_and(|record| record.manual);
// The cover beside the text, and the widest, largest picture there is
// behind all of it — two textures, because the band is drawn across the
// whole window and a tile-sized one stretched that far is soft.
let picture = art.poster(title);
let wide = art.wide(title);
// Reserved before the header is laid out, because the band is as tall as
// whatever the header comes to — a fixed height would clip a long overview
// on one title and leave a gap under a short one on the next.
let backdrop = wide
.as_ref()
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
let shown = art.name(title).to_owned();
ui.add_space(theme::space::S);
ui.horizontal_top(|ui| {
match &picture {
// A poster is drawn as a poster. Fitted into the 16:9 tile the grid
// uses, it was a narrow strip of picture between two bars.
Some((texture, pstr_core::metadata::ArtShape::Portrait)) => {
poster(ui, texture, title.resume().and_then(|e| e.progress()));
}
_ => {
ui::card(
ui,
Card {
art: picture,
name: &shown,
subtitle: String::new(),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: None,
width: theme::CARD_WIDTH,
aspect: theme::CARD_ASPECT,
},
);
}
}
ui.add_space(theme::space::XXL);
ui.vertical(|ui| {
// Text in a column a person can read, with the picture to the
// right of it rather than under it.
ui.set_max_width(ui.available_width().min(820.0));
if ui::logo_heading(ui, art, title, 120.0).is_none() {
ui.label(theme::Role::Hero.rich(&shown).color(theme::text()));
}
// Whichever of the share's name and the provider's is not the one
// shown. Worth showing rather than only replacing: a viewer should
// be able to tell what the match actually matched, and find the
// folder it came from.
let other = found
.as_ref()
.map(|found| {
if shown == found.name {
title.name.clone()
} else {
found.name.clone()
}
})
.filter(|other| *other != shown);
if let Some(other) = other {
ui.label(ui::muted(format!(
"also known as {other}{}",
if by_hand { " (chosen by hand)" } else { "" }
)));
}
ui.add_space(theme::space::XS);
ui.label(ui::muted(meta_line(title, found.as_ref())));
// 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();
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);
}
});
}
ui.add_space(theme::space::L);
ui.horizontal(|ui| {
if let Some(episode) = title.next_up() {
let resuming = episode.resume_at().is_some();
// Where it resumes, in the button that resumes it. As a
// label of its own it ended up three controls further
// along, after the download buttons, reading as a fact
// about those.
let at = episode
.resume_at()
.map(|at| format!(" · {}", ui::format_time(at)))
.unwrap_or_default();
let label = match (resuming, episode.numbering()) {
(true, Some(numbering)) => format!("Resume {numbering}{at}"),
(true, None) => format!("Resume{at}"),
(false, Some(numbering)) if title.kind == TitleKind::Series => {
format!("Play {numbering}")
}
_ => "Play".to_string(),
};
if ui::accent_button(ui, &label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
// Beside the button it is the alternative to.
if resuming
&& ui::tonal_button(ui, "Start over")
.on_hover_text("Play from the beginning")
.clicked()
{
actions.push(Action::Play(PlaybackTarget::from_node(
title,
&episode.node,
None,
)));
}
let title_keys: Vec<_> = title.episodes().map(key_of).collect();
let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
if all_offline {
if ui::tonal_button(ui, "Delete show")
.on_hover_text("Delete the offline copies; keep the online source")
.clicked()
{
actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
}
} else if ui::tonal_button(ui, "Download show")
.on_hover_text("Download every episode for disconnected playback")
.clicked()
{
actions.push(Action::MakeOffline(
title
.episodes()
.map(|episode| PlaybackTarget::new(title, episode))
.collect(),
));
}
let active = offline
.downloads
.iter()
.filter(|item| {
item.target.title_key == title.key
&& matches!(
item.state,
DownloadState::Queued
| DownloadState::Running
| DownloadState::Paused
)
})
.count();
if active > 0 {
ui.label(ui::muted(format!("{active} downloading")));
} else if all_offline {
ui.label(ui::muted("Available offline"));
}
}
// The way out of a match the scorer would not make, or made
// wrongly. Offered whether or not anything matched: the two
// cases it exists for are a title with no poster at all and a
// title wearing someone else's.
if ui::tonal_button(
ui,
match &found {
Some(_) => "Change match",
None => "Match…",
},
)
.on_hover_text("Search the metadata provider and pick the entry yourself")
.clicked()
{
actions.push(Action::OpenMatcher(title.key.clone()));
}
});
if let Some(found) = &found {
ui.add_space(theme::space::L);
description(ui, found);
}
});
});
if let Some((texture, index)) = backdrop {
ui.add_space(theme::space::L);
band(ui, &texture, index, art.mood(title));
}
}
/// The picture again, across the whole width, behind the header.
///
/// The one piece of this page that had to wait for artwork to exist at all: the
/// providers already hand back a picture and its shape, so a backdrop costs no
/// new request — only somewhere to put it.
///
/// Three shapes over one another, in this order: the picture, a flat veil of the
/// page colour so text laid over it stays readable whatever the still happens to
/// be, and a fade into the page along the bottom so the band ends rather than
/// stops.
fn band(
ui: &mut egui::Ui,
texture: &egui::TextureHandle,
index: egui::layers::ShapeIdx,
mood: Option<egui::Color32>,
) {
// Full width of the window, not of the content: the panel's own margin is
// what the band has to reach past, or it reads as a picture in a box.
const BLEED: f32 = super::PAGE_MARGIN.x;
let content = ui.min_rect();
let rect = egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left() - BLEED, content.top()),
egui::pos2(ui.max_rect().right() + BLEED, content.bottom()),
);
// Past the panel margin, or the bleed is trimmed off at exactly the edge it
// exists to cross.
let painter = ui.painter().with_clip_rect(rect);
// Every layer of the band goes into the one slot reserved before the header
// was laid out. Painting the veils here, after the fact, would put them over
// the name and the overview as well as the still — which is the picture
// being readable at the cost of the text it exists behind.
let mut layers: Vec<egui::Shape> = vec![
egui::epaint::RectShape::filled(rect, egui::CornerRadius::ZERO, egui::Color32::WHITE)
.with_texture(
texture.id(),
super::cover_uv(texture.size_vec2(), rect.size()),
)
.into(),
];
// Heavier than it looks: a still is arbitrary, and the one it lands on may
// be a white frame. This is the difference between a hero and an unreadable
// page.
//
// Two coats rather than one heavy one. The first is flat and near-opaque —
// enough on its own that body text over the busiest frame still reads. The
// second is the picture's own contrast being knocked back further behind the
// left half, where the poster, the name and the overview actually sit; the
// right half keeps more of the still, so the band is still a picture.
//
// Heavier still when there is no fade coming, since then these are the only
// things standing between the text and the picture.
//
// Both in the page colour tinted by the picture's own, so each title's
// page carries its light; the fade at the bottom stays the page's, since
// that is what it ends in.
let ramps = theme::ramps_on();
let veil = theme::mood_veil(mood);
layers.push(
egui::epaint::RectShape::filled(
rect,
egui::CornerRadius::ZERO,
// Lighter with the ramps on: the horizontal one below is solid
// where the text is, so this coat only has to calm the picture's
// right side, not make it disappear.
veil.gamma_multiply(if ramps { 0.72 } else { 0.94 }),
)
.into(),
);
if ramps {
let text_side = egui::Rect::from_min_max(
rect.min,
egui::pos2(rect.left() + rect.width() * 0.72, rect.bottom()),
);
layers.push(theme::fade_shape(
ui.ctx(),
text_side,
veil,
theme::Direction::Horizontal,
// Solid at the left, where the poster and the text are.
false,
));
}
// A viewer with ramps off is on a panel that bands them; a 120-point ramp
// of one hue is exactly the case that shows it. The band then ends at its
// own edge, which is the honest version of the same thing.
if ramps {
let fade =
egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 120.0), rect.max);
layers.push(theme::fade_shape(
ui.ctx(),
fade,
theme::background(),
theme::Direction::Vertical,
// Solid at the bottom, where the page it hands over to is.
true,
));
}
painter.set(index, egui::Shape::Vec(layers));
}
/// What the provider had to say. Only ever drawn under a match.
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
let tags = found
.details
.as_ref()
.map(|details| details.tags.as_slice())
.unwrap_or_default();
if !found.genres.is_empty() || !tags.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for genre in &found.genres {
ui::widgets::chip(ui, genre);
}
// After the genres and quieter than them: a tag is the
// provider's community describing it, not what it is filed as.
for tag in tags.iter().take(5) {
ui.label(ui::muted(tag));
}
});
ui.add_space(theme::space::M);
}
if let Some(overview) = &found.overview {
// Four lines, then "More". A provider's synopsis runs to three
// paragraphs on a long show, and all of them pushed the episodes —
// what the page is for — below the fold.
const LINES: usize = 4;
let id = ui.id().with(("overview", &found.remote_id));
let open = ui.data(|data| data.get_temp::<bool>(id)).unwrap_or(false);
let mut job = egui::text::LayoutJob::simple(
overview.clone(),
theme::Role::Label.font(),
theme::text(),
ui.available_width(),
);
if !open {
job.wrap.max_rows = LINES;
job.wrap.overflow_character = Some('…');
}
let galley = ui.fonts_mut(|fonts| fonts.layout_job(job));
let clipped = galley.elided;
ui.label(galley);
if clipped || open {
let toggle = ui.add(
egui::Label::new(
theme::Role::Caption
.rich(if open { "Less" } else { "More" })
.color(theme::accent()),
)
.sense(egui::Sense::click()),
);
if toggle
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
ui.data_mut(|data| data.insert_temp(id, !open));
}
}
}
if let Some(url) = &found.url {
ui.add_space(theme::space::M);
ui.hyperlink_to(
ui::muted(format!("More on {}", found.provider.label())),
url,
);
}
}
/// The grey line under the name: what the library knows, plus what the provider
/// added. The library's own counts come first — they describe the files that are
/// actually there, which is what a viewer is deciding about.
fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(
match title.kind {
TitleKind::Series => "Series",
TitleKind::Film => "Film",
}
.to_string(),
);
if let Some(year) = title.year {
parts.push(year.to_string());
}
// A film's running time is what it costs to start it. A series' is its
// episodes', which each row says.
if title.kind == TitleKind::Film
&& let Some(minutes) = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.runtime)
{
parts.push(ui::format_runtime(minutes));
}
if title.kind == TitleKind::Series {
if title.seasons.len() > 1 {
parts.push(ui::library::plural(title.seasons.len(), "season"));
}
parts.push(ui::library::plural(title.episode_count(), "episode"));
}
let watched = title.watched_count();
if watched > 0 {
parts.push(format!("{watched} watched"));
}
if let Some(details) = found.and_then(|found| found.details.as_ref()) {
if let Some(season) = details.season_label() {
parts.push(season);
}
parts.extend(details.studios.iter().take(2).cloned());
if let Some(director) = details.directors.first() {
parts.push(format!("dir. {director}"));
}
}
if let Some(found) = found {
if let Some(rating) = found.rating {
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
}
// Only when it disagrees with what is on disk: "25 episodes · 25
// episodes" tells nobody anything, but "12 episodes · 25 on AniList"
// says the share is missing half a season.
if let Some(total) = found.episodes
&& title.kind == TitleKind::Series
&& total as usize != title.episode_count()
{
parts.push(format!("{total} on {}", found.provider.label()));
}
}
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);
/// One row per file: its still, its name and what it is about, and the two
/// things that can be done to it besides playing it.
fn episode_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
season: &Season,
episode: &Episode,
offline: &OfflineView<'_>,
actions: &mut Vec<Action>,
) {
// Cloned out before `ui` borrows: the row draws while `art` is held.
let found = art.episode(&title.key, episode).map(|found| {
(
found.name.clone(),
found.overview.clone(),
found.air_date.clone(),
found.runtime,
)
});
let watched = episode.is_watched();
let row_id = ui.id().with(("episode", &episode.node.link_id));
let frame = egui::Frame::new()
.corner_radius(egui::CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::same(8));
let mut prepared = frame.begin(ui);
{
let ui = &mut prepared.content_ui;
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
still(ui, art, title, episode, watched, STILL, actions);
ui.add_space(theme::space::S);
let controls = 96.0;
let text_width = (ui.available_width() - controls).max(120.0);
ui.vertical(|ui| {
ui.set_width(text_width);
ui.spacing_mut().item_spacing.y = 3.0;
// The provider's name for the episode, when there is one:
// "The Immortal Legion" reads as an episode, and
// "[Reaktor] … E57 v2 [1080p][x265].mkv" reads as a filename.
let numbering = episode
.numbering()
.or_else(|| season.number.map(|number| format!("S{number:02}")))
.unwrap_or_default();
let name = found
.as_ref()
.and_then(|(name, ..)| name.clone())
.unwrap_or_else(|| episode.detail().to_string());
let mut job = egui::text::LayoutJob::default();
if !numbering.is_empty() {
job.append(
&format!("{numbering} "),
0.0,
egui::TextFormat::simple(
egui::FontId::new(theme::Role::Label.size(), theme::semibold()),
theme::muted(),
),
);
}
job.append(
&name,
0.0,
egui::TextFormat::simple(
theme::Role::Body.font(),
if watched {
theme::muted()
} else {
theme::text()
},
),
);
job.wrap.max_rows = 1;
job.wrap.overflow_character = Some('…');
ui.add(egui::Label::new(job).truncate())
.on_hover_text(&episode.node.name);
ui.label(ui::muted(details(
episode,
found.as_ref().and_then(|f| f.2.as_deref()),
found.as_ref().and_then(|f| f.3),
)));
if let Some((_, Some(overview), ..)) = &found {
let mut job = egui::text::LayoutJob::simple(
overview.clone(),
theme::Role::Caption.font(),
theme::muted(),
text_width,
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
ui.add(egui::Label::new(job)).on_hover_text(overview);
}
});
// A fixed height, the still's. `with_layout` would hand the
// buttons all the height left in the scroll area's viewport and
// centre them in it, so whichever row sat highest on screen
// stretched down to the bottom of the window.
let cluster = egui::vec2(ui.available_width(), STILL.y);
let layout = egui::Layout::right_to_left(egui::Align::Center);
ui.allocate_ui_with_layout(cluster, layout, |ui| {
let mark = ui::widgets::watched_mark(ui, watched).on_hover_text(if watched {
"Mark unwatched. Right-click for more."
} else {
"Mark watched. Right-click for more."
});
// Joining a show partway is the usual reason to mark
// anything, and one tick per earlier episode is a chore.
mark.context_menu(|ui| {
if ui.button("Mark watched up to here").clicked() {
let links = title
.episodes()
.take(
title
.position_of(&episode.node.share_id, &episode.node.link_id)
.map_or(0, |at| at + 1),
)
.map(|e| (e.node.share_id.clone(), e.node.link_id.clone()))
.collect();
actions.push(Action::MarkEpisodes {
key: title.key.clone(),
links,
watched: true,
what: format!("{} up to {}", title.name, episode.label()),
});
}
});
if mark.clicked() {
actions.push(Action::SetWatched {
share_id: episode.node.share_id.clone(),
link_id: episode.node.link_id.clone(),
watched: !watched,
duration: episode.watch.and_then(|watch| watch.duration_secs),
});
}
ui.add_space(theme::space::S);
download(ui, title, episode, offline, actions);
});
});
}
// The row lights up under the pointer, so it is clear which file the
// buttons on the right belong to in a list of thirty.
let response = prepared.allocate_space(ui);
let lit = ui.ctx().animate_bool_with_time(
row_id,
response.hovered() || response.contains_pointer(),
theme::motion::HOVER,
);
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
prepared.paint(ui);
ui.add_space(theme::space::XXS);
}
/// The grey line under an episode's name: when it aired, how long it runs,
/// how big it is, and where it would resume.
fn details(episode: &Episode, air_date: Option<&str>, runtime: Option<u32>) -> String {
let mut parts = Vec::new();
if let Some(at) = episode.resume_at() {
let left = episode
.watch
.and_then(|watch| watch.duration_secs)
.map(|duration| format!(" · {} left", ui::format_time(duration - at)))
.unwrap_or_default();
parts.push(format!("Resume at {}{left}", ui::format_time(at)));
} else if episode.is_watched() {
parts.push("Watched".into());
}
if let Some(date) = air_date {
parts.push(date.to_owned());
}
if let Some(minutes) = runtime {
parts.push(ui::format_runtime(minutes));
}
match episode.node.size {
// A file the share says is empty is one that will not play — an upload
// that never finished, usually. Worth saying on the row rather than
// only when a click on it comes back with "has no content".
Some(0) => parts.push("empty — this file cannot be played".into()),
Some(size) => parts.push(ui::format_size(size)),
None => {}
}
parts.join(" · ")
}
/// The still, which is also the play button.
///
/// An explicit target rather than a clickable row: the row also carries the
/// watched mark and the download button, and a click that meant either of
/// those must never be one that starts a 1.4 GiB stream instead.
pub(crate) fn still(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
episode: &Episode,
watched: bool,
size: egui::Vec2,
actions: &mut Vec<Action>,
) {
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
let response = response.named(format!("Play {}", episode.label()));
if !ui.is_rect_visible(rect) {
return;
}
let radius = egui::CornerRadius::same(theme::radius::MD);
let painter = ui.painter();
painter.rect_filled(rect, radius, theme::card_hover());
// Asked for only once the row is on screen: a season of twenty-five is
// twenty-five requests, and most of them are never scrolled to.
let texture = art.still(&title.key, episode);
// Faded in like a tile's art, so a season's stills settle rather than pop.
let art_in = ui.ctx().animate_bool_with_time(
response.id.with("art"),
texture.is_some(),
theme::motion::ARRIVE,
);
if let Some(texture) = texture {
let tint = if watched {
egui::Color32::from_gray(150)
} else {
egui::Color32::WHITE
}
.gamma_multiply(art_in);
painter.add(
egui::epaint::RectShape::filled(rect, radius, tint)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
);
}
if response.gained_focus() {
response.scroll_to_me(None);
}
let hover = ui.ctx().animate_bool_with_time(
response.id,
response.hovered() || response.has_focus(),
theme::motion::HOVER,
);
if hover > 0.0 {
painter.rect_filled(
rect,
radius,
egui::Color32::from_black_alpha((90.0 * hover) as u8),
);
}
if response.has_focus() {
painter.rect_stroke(
rect,
radius,
egui::Stroke::new(2.0, theme::accent()),
egui::StrokeKind::Inside,
);
}
// A play mark, always faintly there and full under the pointer.
let centre = rect.center();
painter.circle_filled(
centre,
18.0,
egui::Color32::from_black_alpha((110.0 + 80.0 * hover) as u8),
);
let ink = egui::Color32::WHITE.gamma_multiply(0.75 + 0.25 * hover);
let r = 7.0;
painter.add(egui::Shape::convex_polygon(
vec![
centre + egui::vec2(-r * 0.6, -r),
centre + egui::vec2(r, 0.0),
centre + egui::vec2(-r * 0.6, r),
],
ink,
egui::Stroke::NONE,
));
if let Some(progress) = episode.progress() {
let track = egui::Rect::from_min_max(
egui::pos2(rect.left(), rect.bottom() - 4.0),
rect.right_bottom(),
);
let painter = painter.with_clip_rect(track);
painter.rect_filled(rect, radius, egui::Color32::from_black_alpha(150));
let mut played = rect;
played.set_width(rect.width() * progress.clamp(0.0, 1.0) as f32);
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() {
"Resume"
} else {
"Play"
})
.clicked()
{
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
}
/// One button that is the file's whole offline story: download it, see it
/// coming, pause it, or delete the copy.
fn download(
ui: &mut egui::Ui,
title: &Title,
episode: &Episode,
offline: &OfflineView<'_>,
actions: &mut Vec<Action>,
) {
use ui::widgets::{DownloadGlyph, download_button};
let key = key_of(episode);
let current = offline.downloads.iter().find(|item| item.key == key);
if offline.files.contains(&key) {
if download_button(ui, DownloadGlyph::Done)
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
.clicked()
{
actions.push(Action::DeleteOffline(vec![key], episode.label()));
}
return;
}
match current.map(|item| (&item.state, item.percent())) {
Some((DownloadState::Running | DownloadState::Queued, fraction)) => {
if download_button(ui, DownloadGlyph::Progress(fraction))
.on_hover_text(format!(
"Downloading, {:.0}%. Click to pause.",
fraction * 100.0
))
.clicked()
{
actions.push(Action::PauseDownload(key));
}
}
Some((
DownloadState::Paused | DownloadState::Cancelled | DownloadState::Failed(_),
fraction,
)) => {
if download_button(ui, DownloadGlyph::Paused(fraction))
.on_hover_text(format!(
"Stopped at {:.0}%. Click to carry on.",
fraction * 100.0
))
.clicked()
{
actions.push(Action::ResumeDownload(key));
}
}
Some((DownloadState::Completed, _)) => {
download_button(ui, DownloadGlyph::Done).on_hover_text("Available offline");
}
None => {
if download_button(ui, DownloadGlyph::Download)
.on_hover_text("Download, to play without a connection")
.clicked()
{
actions.push(Action::MakeOffline(vec![PlaybackTarget::new(
title, episode,
)]));
}
}
}
}
fn key_of(episode: &Episode) -> DownloadKey {
DownloadKey {
share_id: episode.node.share_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
);
}
}