//! 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, } pub fn show( ui: &mut egui::Ui, art: &mut Art<'_>, library: &Library, key: &str, franchise: Option<&[String]>, offline: OfflineView<'_>, actions: &mut Vec, ) { 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::(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 = title.seasons.iter().map(|season| season.label()).collect(); let choices: Vec<(usize, &str)> = labels .iter() .enumerate() .map(|(index, label)| (index, label.as_str())) .collect(); if let Some(index) = ui::widgets::segmented(ui, picked, &choices) { 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, ) { egui::ScrollArea::horizontal() .id_salt(("franchise", ¤t.key)) .auto_shrink([false, true]) .show(ui, |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) { 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) { let all = season.episodes.iter().all(|episode| episode.is_watched()); let label = if all { "Mark season unwatched" } else { "Mark season watched" }; if ui.button(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, ) { 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, ) { // 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)); ui.label(theme::Role::Display.rich(&shown).strong()); // 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()))); 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 .button("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 .button("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 .button("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 .button(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); } } /// 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) { // 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 = 18.0; 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 = 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. let ramps = theme::ramps_on(); 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. theme::background().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, theme::background(), 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::(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 = 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()); } 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}")); } 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 { 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(" · ") } /// 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, ) { // 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(), ) }); 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::monospace(13.0), 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()), ))); 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 big it is, and /// where it would resume. fn details(episode: &Episode, air_date: Option<&str>) -> 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()); } 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, ) { 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. if let Some(texture) = art.still(&title.key, episode) { let tint = if watched { egui::Color32::from_gray(150) } else { egui::Color32::WHITE }; 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); } 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, ) { 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(), } }