diff --git a/Cargo.lock b/Cargo.lock index bea9b35..32a9491 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4404,6 +4404,7 @@ name = "pstr-app" version = "0.4.2" dependencies = [ "anyhow", + "chrono", "dbus", "directories", "eframe", diff --git a/Cargo.toml b/Cargo.toml index 10667f1..89089db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,8 @@ anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } directories = "5" +# Local dates for the history page. Already in the tree through the SDK. +chrono = { version = "0.4", default-features = false, features = ["clock"] } sha2 = "0.10" # `bundled` so a Windows build needs no system SQLite. rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/crates/pstr-app/Cargo.toml b/crates/pstr-app/Cargo.toml index b898285..93c212d 100644 --- a/crates/pstr-app/Cargo.toml +++ b/crates/pstr-app/Cargo.toml @@ -31,6 +31,7 @@ parking_lot.workspace = true serde.workspace = true serde_json.workspace = true directories.workspace = true +chrono.workspace = true souvlaki.workspace = true [target.'cfg(target_os = "linux")'.dependencies] diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 4af1652..c22be8f 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -46,6 +46,7 @@ impl FrameTimer { started: std::time::Instant::now(), page: match page { Page::Library => "library", + Page::History => "history", Page::Title(_) => "title", Page::Shares => "shares", Page::Downloads => "downloads", @@ -80,6 +81,8 @@ const UP_NEXT_SECONDS: f64 = 10.0; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Page { Library, + /// Everything played, newest first. + History, /// One title, by [`pstr_core::library::Title::key`]. Title(String), Shares, @@ -91,6 +94,15 @@ pub enum Page { Player, } +/// The pages in the top bar, in order. Ctrl+1 to Ctrl+5 go by position here. +const NAV: [Page; 5] = [ + Page::Library, + Page::History, + Page::Shares, + Page::Downloads, + Page::Settings, +]; + /// Something a click asked for, applied once the frame is drawn. pub enum Action { Goto(Page), @@ -177,6 +189,12 @@ pub enum Action { share_id: String, link_id: String, }, + /// Take one file off the history page: forget its position and its + /// watched mark, with an Undo. + RemoveFromHistory { + share_id: String, + link_id: String, + }, /// Put watch states back as they were — what Undo does. RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>), ClearStreamCache, @@ -293,6 +311,8 @@ pub struct LibraryView { built_with: (Filter, Sort), /// Indices into [`Library::titles`]. pub matches: Vec, + /// Every played episode, newest first, for the history page. + pub history: Vec, /// The "Continue watching" shelf, most recent first, as indices. pub resumable: Vec, /// The library changed under the lists. @@ -366,6 +386,7 @@ impl LibraryView { self.matches = matches.into_iter().filter_map(index_of).collect(); self.built_with = (self.filter, self.sort); if self.stale { + self.history = ui::history::entries(library); self.resumable = library .continue_watching() .into_iter() @@ -1053,6 +1074,39 @@ impl App { self.engine.set_playback_prefs(prefs, commit); } + /// Reset one file to never played, with an Undo. What leaving Continue + /// watching and leaving the history both come down to: the two are read + /// off the same watch state. + fn forget_watch( + &mut self, + ctx: &egui::Context, + share_id: String, + link_id: String, + place: &str, + ) { + let found = self.library.titles.iter().find_map(|title| { + let episode = title.episodes().find(|episode| { + episode.node.share_id == share_id && episode.node.link_id == link_id + })?; + Some((title.name.clone(), episode.watch)) + }); + let Some((name, Some(before))) = found else { + return; + }; + self.engine.save_watch_state( + share_id.clone(), + link_id.clone(), + watch_state(0.0, before.duration_secs, false), + ); + let now = ctx.input(|input| input.time); + self.toasts.push_with( + now, + format!("removed {name} from {place}"), + "Undo", + Action::RestoreWatch(vec![(share_id, link_id, before)]), + ); + } + fn apply(&mut self, ctx: &egui::Context, action: Action) { match action { Action::Goto(page) => self.page = page, @@ -1318,27 +1372,10 @@ impl App { ); } Action::ForgetPosition { share_id, link_id } => { - let found = self.library.titles.iter().find_map(|title| { - let episode = title.episodes().find(|episode| { - episode.node.share_id == share_id && episode.node.link_id == link_id - })?; - Some((title.name.clone(), episode.watch)) - }); - let Some((name, Some(before))) = found else { - return; - }; - self.engine.save_watch_state( - share_id.clone(), - link_id.clone(), - watch_state(0.0, before.duration_secs, false), - ); - let now = ctx.input(|input| input.time); - self.toasts.push_with( - now, - format!("removed {name} from Continue watching"), - "Undo", - Action::RestoreWatch(vec![(share_id, link_id, before)]), - ); + self.forget_watch(ctx, share_id, link_id, "Continue watching"); + } + Action::RemoveFromHistory { share_id, link_id } => { + self.forget_watch(ctx, share_id, link_id, "history"); } Action::RestoreWatch(states) => { for (share_id, link_id, state) in states { @@ -1459,12 +1496,13 @@ impl App { } else { format!("Downloads ({active})") }; - let pages = [Page::Library, Page::Shares, Page::Downloads, Page::Settings]; + let pages = NAV; let clicked = ui::tabs( ui, ui.id().with("nav"), &[ ("Library", on_library), + ("History", self.page == Page::History), ("Shares", self.page == Page::Shares), (&downloads, self.page == Page::Downloads), ("Settings", self.page == Page::Settings), @@ -1776,6 +1814,9 @@ impl eframe::App for App { ui::settings::show(ui, settings, prefs, api_key, &mut actions) } Page::Downloads => ui::downloads::show(ui, downloads, &mut actions), + Page::History => { + ui::history::show(ui, &mut art, library, &view.history, &mut actions) + } // Drawn above, without any of these panels. Page::Player => {} } @@ -2008,13 +2049,14 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec) (Key::Num2, 1), (Key::Num3, 2), (Key::Num4, 3), + (Key::Num5, 4), ] { if input.consume_key(Modifiers::COMMAND, key) { tab = Some(index); } } if input.consume_key(Modifiers::COMMAND, Key::Comma) { - tab = Some(3); + tab = Some(4); } // A share link pasted anywhere outside a text field is someone adding // a share, wherever they happen to be. @@ -2029,7 +2071,7 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec) } }); if let Some(index) = tab { - let page = [Page::Library, Page::Shares, Page::Downloads, Page::Settings][index].clone(); + let page = NAV[index].clone(); actions.push(Action::Goto(page)); } } diff --git a/crates/pstr-app/src/ui/history.rs b/crates/pstr-app/src/ui/history.rs new file mode 100644 index 0000000..e25579a --- /dev/null +++ b/crates/pstr-app/src/ui/history.rs @@ -0,0 +1,238 @@ +//! Everything that has been played, newest first, under a heading per day. +//! +//! Continue watching answers "what was I in the middle of"; this answers +//! "what was that episode on Tuesday". It is read straight off the watch +//! state, so there is nothing to keep in step: an episode is here because it +//! has a position or is marked watched, and leaves when it has neither. + +use chrono::{DateTime, Local, NaiveDate, TimeZone}; +use pstr_core::library::{Episode, Library, Title, TitleKind}; + +use crate::app::{Action, Page}; +use crate::theme; +use crate::ui::{self, Art}; + +const STILL: egui::Vec2 = egui::vec2(128.0, 72.0); +/// How far back the page goes. Further than anyone scrolls; the cap is there +/// so a library with years of history does not lay out every row of it. +const MOST: usize = 400; + +/// Where one entry lives in [`Library::titles`]: title, season, episode. +pub type Entry = (usize, usize, usize); + +/// The entries of `library.history()`, as positions, so they can be kept +/// between frames and only rebuilt when the library changes. +pub fn entries(library: &Library) -> Vec { + let mut position = std::collections::HashMap::new(); + for (t, title) in library.titles.iter().enumerate() { + for (s, season) in title.seasons.iter().enumerate() { + for (e, episode) in season.episodes.iter().enumerate() { + position.insert(std::ptr::from_ref(episode), (t, s, e)); + } + } + } + library + .history() + .into_iter() + .filter_map(|(_, episode)| position.get(&std::ptr::from_ref(episode)).copied()) + .collect() +} + +pub fn show( + ui: &mut egui::Ui, + art: &mut Art<'_>, + library: &Library, + entries: &[Entry], + actions: &mut Vec, +) { + if entries.is_empty() { + ui::empty_state( + ui, + "Nothing watched yet", + "Episodes and films you play show up here, newest first, so the one from \ + last week is easy to find again.", + ); + return; + } + + let today = Local::now().date_naive(); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_max_width(960.0); + let mut day = None; + for &(t, s, e) in entries.iter().take(MOST) { + let Some(title) = library.titles.get(t) else { + continue; + }; + let Some(episode) = title + .seasons + .get(s) + .and_then(|season| season.episodes.get(e)) + else { + continue; + }; + let Some(at) = Local.timestamp_opt(episode.last_played(), 0).single() else { + continue; + }; + if day != Some(at.date_naive()) { + if day.is_some() { + ui.add_space(theme::space::L); + } + day = Some(at.date_naive()); + ui::section(ui, &day_label(at.date_naive(), today)); + } + row(ui, art, title, episode, at, actions); + } + }); +} + +fn row( + ui: &mut egui::Ui, + art: &mut Art<'_>, + title: &Title, + episode: &Episode, + at: DateTime, + actions: &mut Vec, +) { + let row_id = ui.id().with(("history", &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| { + ui::title::still( + ui, + art, + title, + episode, + episode.is_watched(), + STILL, + actions, + ); + ui.add_space(theme::space::M); + + let remove = 40.0; + let text_width = (ui.available_width() - remove).max(120.0); + ui.vertical(|ui| { + ui.set_width(text_width); + ui.spacing_mut().item_spacing.y = theme::space::XXS; + ui.add_space(theme::space::XS); + let name = ui + .add( + egui::Label::new(theme::Role::Body.rich(&title.name).color(theme::text())) + .sense(egui::Sense::click()) + .truncate(), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text("Show this title"); + if name.clicked() { + actions.push(Action::Goto(Page::Title(title.key.clone()))); + } + // A film's only "episode" is its file, and its filename says + // nothing the title above it does not. + if title.kind != TitleKind::Film { + let episode_name = art + .episode(&title.key, episode) + .and_then(|found| found.name.clone()) + .unwrap_or_else(|| episode.detail().to_owned()); + let line = match episode.numbering() { + Some(numbering) => format!("{numbering} · {episode_name}"), + None => episode_name, + }; + ui.add(egui::Label::new(ui::muted(line)).truncate()) + .on_hover_text(&episode.node.name); + } + ui.label(ui::muted(status(episode, at))); + }); + + 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 remove = ui + .add( + egui::Button::new( + theme::Role::Subhead + .rich(egui_phosphor::regular::X) + .color(theme::muted()), + ) + .frame(false), + ) + .on_hover_text(if episode.is_watched() { + "Remove from history — marks it unwatched" + } else { + "Remove from history — forgets where you stopped" + }); + if remove.clicked() { + actions.push(Action::RemoveFromHistory { + share_id: episode.node.share_id.clone(), + link_id: episode.node.link_id.clone(), + }); + } + }); + }); + } + let response = prepared.allocate_space(ui); + let lit = + ui.ctx() + .animate_bool_with_time(row_id, 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 the names: how far it got, and when. +fn status(episode: &Episode, at: DateTime) -> String { + let when = at.format("%H:%M"); + if episode.is_watched() { + return format!("Watched · {when}"); + } + match ( + episode.resume_at(), + episode.watch.and_then(|watch| watch.duration_secs), + ) { + (Some(position), Some(duration)) => format!( + "Stopped at {} of {} · {when}", + ui::format_time(position), + ui::format_time(duration) + ), + (Some(position), None) => format!("Stopped at {} · {when}", ui::format_time(position)), + _ => format!("Played · {when}"), + } +} + +/// The heading over a day's entries: "Today", "Yesterday", a weekday within +/// the last week, and a date before that — with the year only when it is not +/// this one. +fn day_label(day: NaiveDate, today: NaiveDate) -> String { + use chrono::Datelike; + match (today - day).num_days() { + 0 => "Today".into(), + 1 => "Yesterday".into(), + 2..=6 => day.format("%A").to_string(), + _ if day.year() == today.year() => day.format("%-d %B").to_string(), + _ => day.format("%-d %B %Y").to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn date(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() + } + + #[test] + fn a_day_is_named_relative_to_today_for_the_last_week_and_dated_before_that() { + let today = date(2026, 10, 2); + assert_eq!(day_label(today, today), "Today"); + assert_eq!(day_label(date(2026, 10, 1), today), "Yesterday"); + assert_eq!(day_label(date(2026, 9, 28), today), "Monday"); + assert_eq!(day_label(date(2026, 9, 25), today), "25 September"); + assert_eq!(day_label(date(2025, 12, 24), today), "24 December 2025"); + } +} diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index 23ebc75..b94f144 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -4,6 +4,7 @@ //! applies them after the frame is drawn. pub mod downloads; +pub mod history; pub mod library; pub mod matcher; pub mod player; diff --git a/crates/pstr-app/src/ui/shortcuts.rs b/crates/pstr-app/src/ui/shortcuts.rs index 407630a..0e7804c 100644 --- a/crates/pstr-app/src/ui/shortcuts.rs +++ b/crates/pstr-app/src/ui/shortcuts.rs @@ -13,7 +13,10 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[ &[ ("Ctrl F or /", "Search the library"), ("F5 or Ctrl R", "Crawl every share again"), - ("Ctrl 1 – 4", "Library, Shares, Downloads, Settings"), + ( + "Ctrl 1 – 5", + "Library, History, Shares, Downloads, Settings", + ), ("Ctrl ,", "Settings"), ("Ctrl V", "Paste a share link to add it"), ("Tab or arrows", "Move between titles and episodes"), diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index b5e4025..dd81506 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -580,7 +580,7 @@ fn episode_row( let ui = &mut prepared.content_ui; ui.set_width(ui.available_width()); ui.horizontal_top(|ui| { - still(ui, art, title, episode, watched, actions); + still(ui, art, title, episode, watched, STILL, actions); ui.add_space(theme::space::S); let controls = 96.0; @@ -716,15 +716,16 @@ fn details(episode: &Episode, air_date: Option<&str>) -> String { /// 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. -fn still( +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(STILL, egui::Sense::click()); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); if !ui.is_rect_visible(rect) { return; } diff --git a/crates/pstr-core/src/library.rs b/crates/pstr-core/src/library.rs index 89ed639..f9d4e9d 100644 --- a/crates/pstr-core/src/library.rs +++ b/crates/pstr-core/src/library.rs @@ -369,6 +369,26 @@ impl Library { titles } + /// Every episode that has been started or finished, most recently + /// touched first. The history page. + /// + /// An episode marked unwatched keeps a row with position zero, which is + /// "never played" for every purpose here, so it is left out. + pub fn history(&self) -> Vec<(&Title, &Episode)> { + let mut played: Vec<(&Title, &Episode)> = self + .titles + .iter() + .flat_map(|title| title.episodes().map(move |episode| (title, episode))) + .filter(|(_, episode)| { + episode + .watch + .is_some_and(|watch| watch.watched || watch.position_secs > 0.0) + }) + .collect(); + played.sort_by_key(|(_, episode)| std::cmp::Reverse(episode.last_played())); + played + } + /// Substring match over the display name and every filename under it, so /// searching for a release group or an episode name finds its title. pub fn search(&self, query: &str) -> Vec<&Title> { @@ -517,6 +537,41 @@ mod tests { } } + #[test] + fn history_is_every_played_episode_newest_first_across_titles() { + let watch_state = HashMap::from([ + ( + ("s".to_string(), "a".to_string()), + watch(600.0, 1400.0, false, 10), + ), + ( + ("s".to_string(), "c".to_string()), + watch(1400.0, 1400.0, true, 30), + ), + // Marked unwatched again: a row at zero, which is not a play. + ( + ("s".to_string(), "b".to_string()), + watch(0.0, 1400.0, false, 40), + ), + ]); + let library = Library::build( + vec![ + node("s", "a", "Show - S01E01", Some(1), Some(1)), + node("s", "b", "Show - S01E02", Some(1), Some(2)), + node("s", "c", "Film", None, None), + node("s", "d", "Other - S01E01", Some(1), Some(1)), + ], + &watch_state, + ); + + let order: Vec<&str> = library + .history() + .iter() + .map(|(_, episode)| episode.node.link_id.as_str()) + .collect(); + assert_eq!(order, ["c", "a"]); + } + #[test] fn groups_episodes_into_seasons() { let nodes = vec![