//! The library page: what to watch, as a wall of stills. use pstr_core::appearance::Tiles; use pstr_core::library::{Library, Title, TitleKind}; use crate::app::{Action, Filter, LibraryView, Page, Sort}; use crate::playback::PlaybackTarget; use crate::theme; use crate::ui::Art; use crate::ui::{self, Card}; /// What the page lists, and what it was asked for. pub struct Shelves<'a> { pub library: &'a Library, /// The search and the shelf, already worked out — see [`LibraryView`]. pub view: &'a LibraryView, /// Whether the catalog has been read yet. pub loaded: bool, pub search: &'a str, /// The grid's shape. pub tiles: Tiles, /// What a crawl in progress has found, if one is. pub crawl: Option<&'a str>, /// Whether to offer posters and synopses: they are off, and the offer has /// not been turned down. pub offer_metadata: bool, } pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec) { let Shelves { library, view, loaded, search, tiles, crawl, offer_metadata, } = shelves; if !loaded { return placeholder(ui); } if library.is_empty() { return empty_state(ui, crawl, actions); } let pick = |indices: &[usize]| -> Vec<&Title> { indices .iter() .filter_map(|&index| library.titles.get(index)) .collect() }; let matches = pick(&view.matches); let searching = !search.trim().is_empty(); egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { if !searching && offer_metadata { metadata_offer(ui, actions); ui.add_space(theme::space::XL); } if !searching { if let Some(title) = featured(library, view, art.metadata, today()) { hero(ui, art, title, actions); ui.add_space(theme::space::XL); } let resumable = pick(&view.resumable); if !resumable.is_empty() { ui::section(ui, "Continue watching"); continue_row(ui, art, &resumable, actions); ui.add_space(theme::space::XL); } // Only over the whole library: under a filter, a shelf of // titles the grid below is hiding reads as the filter not // working. if view.filter == Filter::All { for (label, indices) in &view.shelves { ui::section(ui, label); shelf_row(ui, art, label, &pick(indices), tiles, actions); ui.add_space(theme::space::XL); } } } ui.horizontal(|ui| { ui::section( ui, &if searching { format!( "{} matching {:?}", plural(matches.len(), "title"), search.trim() ) } else { plural(matches.len(), "title") }, ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { shelf_controls(ui, view, actions); }); }); ui.add_space(theme::space::S); if matches.is_empty() { ui.add_space(theme::space::M); ui.label(ui::muted(if searching { "Nothing here by that name." } else { "Nothing in the library fits that filter." })); return; } grid(ui, art, &matches, view, tiles, actions); }); } /// Days since the epoch: what the featured title turns over on. fn today() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |since| since.as_secs() / 86_400) } /// The title the banner shows: whatever was watched last, so the top of the /// page is one click from carrying on. With nothing part-watched, one of the /// titles a provider has a backdrop for, turning over once a day — the same /// one every time the page is opened today, rather than a new one per frame. fn featured<'a>( library: &'a Library, view: &LibraryView, metadata: &std::collections::HashMap, day: u64, ) -> Option<&'a Title> { if let Some(title) = view .resumable .first() .and_then(|&index| library.titles.get(index)) { return Some(title); } let pictured: Vec<&Title> = library .titles .iter() .filter(|title| { metadata .get(&title.key) .and_then(|record| record.metadata.as_ref()) .is_some_and(|found| found.wide_art().is_some()) }) .collect(); if pictured.is_empty() { return None; } pictured .get((day % pictured.len() as u64) as usize) .copied() } /// The banner across the top of the library: one title, large, with what it /// is and a way to play it. fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec) { let height = (ui.ctx().content_rect().height() * 0.42).clamp(220.0, 360.0); let (rect, response) = ui.allocate_exact_size( egui::vec2(ui.available_width(), height), egui::Sense::click(), ); if !ui.is_rect_visible(rect) { return; } let radius = egui::CornerRadius::same(theme::radius::LG); let picture = art.wide(title); let found = art .metadata .get(&title.key) .and_then(|record| record.metadata.clone()); let painter = ui.painter().with_clip_rect(rect); painter.rect_filled(rect, radius, theme::card()); let art_in = ui.ctx().animate_bool_with_time( response.id.with("art"), picture.is_some(), theme::motion::ARRIVE, ); if art_in < 1.0 { ui::placeholder(&painter, rect, "", 1.0 - art_in); } if let Some((texture, _)) = &picture { painter.add( egui::epaint::RectShape::filled( rect, radius, egui::Color32::WHITE.gamma_multiply(art_in), ) .with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())), ); } // The same two coats as the title page's band, for the same reason: a // still is arbitrary, and the text over it has to read whatever it is. if theme::ramps_on() { painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.25)); let text_side = egui::Rect::from_min_max( rect.min, egui::pos2(rect.left() + rect.width() * 0.7, rect.bottom()), ); painter.add(theme::fade_shape( ui.ctx(), text_side, theme::background(), theme::Direction::Horizontal, false, )); let bottom = egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 90.0), rect.max); painter.add(theme::fade_shape( ui.ctx(), bottom, theme::background(), theme::Direction::Vertical, true, )); } else { painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.82)); } let inner = egui::Rect::from_min_max( rect.min + egui::vec2(32.0, 0.0), egui::pos2( rect.left() + (rect.width() * 0.55).max(360.0), rect.bottom() - 28.0, ), ); // With no wide art the banner is the poster's wash, so the poster itself // stands at the right, where wide art keeps its subject — over the veils, // and only where it clears the text. if !art.has_wide_art(title) && let Some((poster, pstr_core::metadata::ArtShape::Portrait)) = art.poster(title) { const INSET: f32 = 28.0; let height = rect.height() - 2.0 * INSET; let size = egui::vec2(height * 2.0 / 3.0, height); let at = egui::Rect::from_min_size( egui::pos2(rect.right() - 2.0 * INSET - size.x, rect.top() + INSET), size, ); if at.left() > inner.right() + INSET { painter.add( egui::epaint::RectShape::filled( at, egui::CornerRadius::same(theme::radius::MD), egui::Color32::WHITE.gamma_multiply(art_in), ) .with_texture( poster.id(), egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), ), ); } } ui.scope_builder( egui::UiBuilder::new() .max_rect(inner) .layout(egui::Layout::bottom_up(egui::Align::Min)), |ui| { ui.horizontal(|ui| { if let Some(episode) = title.next_up() { let label = match (title.resume(), episode.numbering()) { (Some(_), Some(numbering)) => { format!("{} Resume {numbering}", egui_phosphor::regular::PLAY) } (Some(_), None) => format!("{} Resume", egui_phosphor::regular::PLAY), (None, _) => format!("{} Play", egui_phosphor::regular::PLAY), }; if ui::accent_button(ui, &label).clicked() { actions.push(Action::Play(PlaybackTarget::new(title, episode))); } ui.add_space(theme::space::S); } let more = format!("{} More info", egui_phosphor::regular::INFO); if ui::tonal_button(ui, &more).clicked() { actions.push(Action::Goto(Page::Title(title.key.clone()))); } }); ui.add_space(theme::space::L); if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) { let mut job = egui::text::LayoutJob::simple( overview.to_owned(), theme::Role::Body.font(), theme::text(), ui.available_width(), ); job.wrap.max_rows = 3; ui.label(job); ui.add_space(theme::space::M); } let mut facts = vec![subtitle(title)]; if let Some(found) = &found { if let Some(rating) = found.rating { facts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR)); } facts.extend(found.genres.iter().take(3).cloned()); } ui.label(ui::muted(facts.join(" · "))); ui.add_space(theme::space::XXS); if ui::logo_heading(ui, art, title, 110.0).is_none() { // Two lines before it gives up: a long romanised name cut to // one at this size kept barely more than its first word. let mut job = egui::text::LayoutJob::simple( art.name(title).to_owned(), theme::Role::Hero.font(), theme::text(), ui.available_width().min(640.0), ); job.wrap.max_rows = 2; job.wrap.overflow_character = Some('…'); ui.label(job); } }, ); if response.clicked() { actions.push(Action::Goto(Page::Title(title.key.clone()))); } } /// The filter pills, the order and the grouping, at the right of the grid's /// heading. fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec) { // Right to left: the order first, so it ends up last. let sort = egui::ComboBox::from_id_salt("library-sort") .selected_text(view.sort.label()) .width(150.0) .show_ui(ui, |ui| { let mut picked = None; for sort in Sort::ALL { if ui .selectable_label(view.sort == sort, sort.label()) .clicked() { picked = Some(sort); } } picked }); if let Some(Some(sort)) = sort.inner { actions.push(Action::SetShelf(view.filter, sort)); } ui.add_space(theme::space::S); let mut grouped = view.grouped; let toggle = ui.toggle_value(&mut grouped, egui_phosphor::regular::STACK); toggle.widget_info(|| { egui::WidgetInfo::selected( egui::WidgetType::SelectableLabel, true, view.grouped, "Group franchises", ) }); if toggle .on_hover_text(if view.grouped { "One tile per franchise. Click to show every title" } else { "Every title. Click to fold each franchise into one tile" }) .changed() { actions.push(Action::SetGrouped(grouped)); } ui.add_space(theme::space::L); let filters = Filter::ALL.map(|filter| (filter, filter.label())); if let Some(filter) = ui::widgets::segmented(ui, view.filter, &filters) { actions.push(Action::SetShelf(filter, view.sort)); } } /// One shelf: titles that share something, scrolling sideways, in the grid's /// own shape. fn shelf_row( ui: &mut egui::Ui, art: &mut Art<'_>, label: &str, titles: &[&Title], tiles: Tiles, actions: &mut Vec, ) { let (width, aspect) = tile_shape(tiles); ui::shelf(ui, ("shelf", label), |ui| { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = theme::CARD_GAP; for title in titles { let picture = match tiles { Tiles::Posters => art.poster(title), Tiles::Stills => art.of(title), }; let response = ui::card( ui, Card { art: picture, name: art.name(title), subtitle: subtitle(title), progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), badge: None, width, aspect, }, ); tile_menu(&response, title, false, actions); if response.clicked() { actions.push(Action::Goto(Page::Title(title.key.clone()))); } } }); }); } /// The minimum width and the picture's shape of a tile. fn tile_shape(tiles: Tiles) -> (f32, f32) { match tiles { Tiles::Posters => (theme::POSTER_WIDTH, theme::POSTER_ASPECT), Tiles::Stills => (theme::CARD_WIDTH, theme::CARD_ASPECT), } } /// The top shelf: one card per title in progress, scrolling sideways. fn continue_row( ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec, ) { ui::shelf(ui, "continue", |ui| { ui.horizontal(|ui| { for title in titles { let Some(episode) = title.continue_with() else { continue; }; let remaining = match episode.resume_at() { Some(at) => episode .watch .and_then(|watch| watch.duration_secs) .map(|duration| format!("{} left", ui::format_time(duration - at))) .unwrap_or_else(|| episode.label()), None => format!("Next: {}", episode.label()), }; let picture = art.of(title); let response = ui::card( ui, Card { art: picture, name: art.name(title), subtitle: remaining, progress: episode.progress().map(|value| value as f32), badge: episode.numbering(), // A shelf that scrolls sideways has no right edge // to reach, so nothing to flex to. Always a // still: it is a frame from where they stopped. width: theme::CARD_WIDTH, aspect: theme::CARD_ASPECT, }, ); tile_menu(&response, title, true, actions); if response.clicked() { actions.push(Action::Goto(Page::Title(title.key.clone()))); } } }); }); } /// Every title, wrapped to the window. fn grid( ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], view: &LibraryView, tiles: Tiles, actions: &mut Vec, ) { let (minimum, aspect) = tile_shape(tiles); let grid = ui::columns_of(ui.available_width(), minimum); let row_height = ui::card_height(grid.width, aspect); for (row, chunk) in titles.chunks(grid.columns).enumerate() { // A row scrolled out of view is only its height. Laying it out anyway // costs a subtitle and an artwork lookup per card per frame, and while // a film plays under the transport bar a frame is every frame. let slot = egui::Rect::from_min_size( ui.cursor().min, egui::vec2(ui.available_width(), row_height), ); if !ui.is_rect_visible(slot) { // The spacing `horizontal` would have added after itself, too, or // the page grows and shrinks under the scroll bar as rows come and // go. ui.add_space(row_height + ui.spacing().item_spacing.y + theme::CARD_GAP); continue; } ui.horizontal(|ui| { // The gap the width was divided around, so what is drawn matches // what was measured. egui's default item spacing is narrower, and // the difference times the column count is a visible drift towards // the left edge. ui.spacing_mut().item_spacing.x = theme::CARD_GAP; for (column, title) in chunk.iter().enumerate() { let picture = match tiles { Tiles::Posters => art.poster(title), Tiles::Stills => art.of(title), }; let folded = view .matches .get(row * grid.columns + column) .and_then(|index| view.folded.get(index)) .copied(); let response = ui::card( ui, Card { art: picture, name: art.name(title), subtitle: match folded { Some(more) => format!("{} · +{more} more", subtitle(title)), None => subtitle(title), }, progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), // What a film is says itself in the subtitle below the // card; a second `Film` over the poster is noise. A // folded franchise says how much is under it. badge: folded.map(|more| format!("+{more}")), width: grid.width, aspect, }, ); tile_menu(&response, title, false, actions); if response.clicked() { actions.push(Action::Goto(Page::Title(title.key.clone()))); } } }); ui.add_space(theme::CARD_GAP); } } /// What a right-click on a tile offers: the things otherwise one page away. /// /// `continuing` is the Continue watching shelf, where the tile can also be /// taken off the shelf. fn tile_menu( response: &egui::Response, title: &Title, continuing: bool, actions: &mut Vec, ) { response.context_menu(|ui| { if let Some(episode) = title.next_up() { let label = match (title.resume(), episode.numbering()) { (Some(_), Some(numbering)) => format!("Resume {numbering}"), (Some(_), None) => "Resume".to_owned(), (None, _) => "Play".to_owned(), }; if ui.button(label).clicked() { actions.push(Action::Play(PlaybackTarget::new(title, episode))); } } if ui.button("Open").clicked() { actions.push(Action::Goto(Page::Title(title.key.clone()))); } ui.separator(); let all_watched = title.watched_count() == title.episode_count(); if ui .button(if all_watched { "Mark unwatched" } else { "Mark watched" }) .clicked() { actions.push(Action::SetTitleWatched { key: title.key.clone(), watched: !all_watched, }); } if continuing && let Some(episode) = title.continue_with() && ui .button("Remove from Continue watching") .on_hover_text("Forget where you stopped in it") .clicked() { actions.push(Action::ForgetPosition { share_id: episode.node.share_id.clone(), link_id: episode.node.link_id.clone(), }); } let count = title.episode_count(); let download = if count == 1 { "Download".to_owned() } else { format!("Download all {count}") }; if ui.button(download).clicked() { actions.push(Action::MakeOffline( title .episodes() .map(|episode| PlaybackTarget::new(title, episode)) .collect(), )); } ui.separator(); if ui.button("Change match…").clicked() { actions.push(Action::OpenMatcher(title.key.clone())); } }); } /// The grey line under a card: what it is, how much there is, and how much is /// left. /// /// A film says so, and says its year after it. The year alone was ambiguous in /// the one place it mattered: `Ghost in the Shell` over `1995` reads as a /// season, an episode count, anything — where every series card on the same /// shelf is counting episodes. fn subtitle(title: &Title) -> String { if title.kind == TitleKind::Film { return match title.year { Some(year) => format!("Film · {year}"), None => "Film".into(), }; } let total = title.episode_count(); let watched = title.watched_count(); if watched == 0 { plural(total, "episode") } else if watched >= total { format!("{} · watched", plural(total, "episode")) } else { format!("{watched} of {total} watched") } } /// The page before the catalog has been read: the shape of a grid, with /// nothing in it yet. /// /// Not the empty state. The catalog is on disk and is read in a fraction of a /// second, and telling every launch "Nothing in the library yet" for that /// fraction is a flash of something false. fn placeholder(ui: &mut egui::Ui) { let grid = ui::columns(ui.available_width()); let height = ui::card_height(grid.width, theme::CARD_ASPECT); ui::section(ui, "Library"); for _ in 0..2 { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = theme::CARD_GAP; for _ in 0..grid.columns { let (rect, _) = ui.allocate_exact_size(egui::vec2(grid.width, height), egui::Sense::hover()); let image = egui::Rect::from_min_size( rect.min, egui::vec2(grid.width, (grid.width * theme::CARD_ASPECT).round()), ); ui.painter().rect_filled( image, egui::CornerRadius::same(theme::radius::MD), theme::card(), ); theme::shimmer(ui.painter(), image); } }); ui.add_space(theme::CARD_GAP); } } /// Posters and synopses, offered once there is a library to put them on and /// never turned on unasked: switching them on sends title names out. fn metadata_offer(ui: &mut egui::Ui, actions: &mut Vec) { egui::Frame::new() .fill(theme::card()) .corner_radius(egui::CornerRadius::same(theme::radius::MD)) .inner_margin(egui::Margin::symmetric(14, 12)) .show(ui, |ui| { ui.set_width(ui.available_width()); ui.horizontal(|ui| { ui.vertical(|ui| { ui.label(egui::RichText::new("Add posters and synopses?").strong()); ui.label(ui::muted( "Turning it on in Settings sends the titles in your library — not your \ files, and nothing about what you have watched — to AniList or TMDB.", )); }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Not now").clicked() { actions.push(Action::DismissMetadataOffer); } if ui::accent_button(ui, "Open settings").clicked() { actions.push(Action::Goto(Page::Settings)); } }); }); }); } fn empty_state(ui: &mut egui::Ui, crawl: Option<&str>, actions: &mut Vec) { ui.vertical_centered(|ui| { ui.add_space(120.0); // A share just added: say it is being looked through, rather than // offer to add the one that is already filling the page. if let Some(crawl) = crawl { ui.add(egui::Spinner::new().size(28.0)); ui.add_space(theme::space::L); ui.label( theme::Role::Title .rich("Looking through the share") .strong(), ); ui.add_space(theme::space::S); ui.label(ui::muted(format!( "Its titles appear here once it has been listed ({crawl})." ))); return; } ui.label( theme::Role::Title .rich("Nothing in the library yet") .strong(), ); ui.add_space(theme::space::S); ui.label(ui::muted( "Add a Proton Drive share link, and everything playable behind it shows up here.", )); ui.add_space(theme::space::XL); if ui::accent_button(ui, "Add a share").clicked() { actions.push(Action::OpenShareForm); } }); } pub fn plural(count: usize, noun: &str) -> String { if count == 1 { format!("1 {noun}") } else { format!("{count} {noun}s") } } #[cfg(test)] mod tests { use super::*; fn title(kind: TitleKind, year: Option) -> Title { Title { key: "k".into(), name: "Ghost in the Shell".into(), year, kind, seasons: Vec::new(), share_ids: Vec::new(), } } /// A film says it is one. The year on its own was read as anything but — /// the cards beside it are all counting episodes. #[test] fn a_film_is_labelled_a_film_and_dated_after_it() { assert_eq!( subtitle(&title(TitleKind::Film, Some(1995))), "Film · 1995" ); assert_eq!(subtitle(&title(TitleKind::Film, None)), "Film"); assert_eq!( subtitle(&title(TitleKind::Series, Some(1995))), "0 episodes" ); } #[test] fn the_banner_features_the_title_watched_last_and_nothing_without_a_picture() { let mut second = title(TitleKind::Film, None); second.key = "second".into(); let library = Library { titles: vec![title(TitleKind::Film, None), second], }; let metadata = std::collections::HashMap::new(); let mut view = LibraryView::default(); assert!(featured(&library, &view, &metadata, 0).is_none()); view.resumable = vec![1]; assert_eq!( featured(&library, &view, &metadata, 0).map(|title| title.key.as_str()), Some("second") ); } #[test] fn plurals_read_naturally() { assert_eq!(plural(1, "title"), "1 title"); assert_eq!(plural(0, "title"), "0 titles"); assert_eq!(plural(12, "episode"), "12 episodes"); } }