feat(core,app): a history page

Every played episode, newest first, under Today, Yesterday, a weekday or a
date. A row shows the still (which plays it), the title (which opens it),
the episode, and where it stopped or that it was finished. Removing a row
resets that file to never played, with an Undo. History sits second in the
top bar, so Ctrl+1 to Ctrl+5 now run Library, History, Shares, Downloads,
Settings.
This commit is contained in:
nvrl committed 2026-10-02 23:17:36 +02:00
1 parent 39866bee8d
commit e7eeaa518f
9 files changed
+372 -28

No files matched your search

+1
View File
@@ -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]
+66 -24
View File
@@ -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<usize>,
/// Every played episode, newest first, for the history page.
pub history: Vec<ui::history::Entry>,
/// The "Continue watching" shelf, most recent first, as indices.
pub resumable: Vec<usize>,
/// 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<Action>)
(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<Action>)
}
});
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));
}
}
+238
View File
@@ -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<Entry> {
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<Action>,
) {
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<Local>,
actions: &mut Vec<Action>,
) {
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<Local>) -> 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");
}
}
+1
View File
@@ -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;
+4 -1
View File
@@ -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"),
+4 -3
View File
@@ -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<Action>,
) {
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;
}