mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
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:
1 parent
39866bee8d
commit
e7eeaa518f
9 files changed
+372
-28
No files matched your search
Generated
+1
@@ -4404,6 +4404,7 @@ name = "pstr-app"
|
|||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
"dbus",
|
"dbus",
|
||||||
"directories",
|
"directories",
|
||||||
"eframe",
|
"eframe",
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ anyhow = "1"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "5"
|
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"
|
sha2 = "0.10"
|
||||||
# `bundled` so a Windows build needs no system SQLite.
|
# `bundled` so a Windows build needs no system SQLite.
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ parking_lot.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
souvlaki.workspace = true
|
souvlaki.workspace = true
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
|||||||
+66
-24
@@ -46,6 +46,7 @@ impl FrameTimer {
|
|||||||
started: std::time::Instant::now(),
|
started: std::time::Instant::now(),
|
||||||
page: match page {
|
page: match page {
|
||||||
Page::Library => "library",
|
Page::Library => "library",
|
||||||
|
Page::History => "history",
|
||||||
Page::Title(_) => "title",
|
Page::Title(_) => "title",
|
||||||
Page::Shares => "shares",
|
Page::Shares => "shares",
|
||||||
Page::Downloads => "downloads",
|
Page::Downloads => "downloads",
|
||||||
@@ -80,6 +81,8 @@ const UP_NEXT_SECONDS: f64 = 10.0;
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Page {
|
pub enum Page {
|
||||||
Library,
|
Library,
|
||||||
|
/// Everything played, newest first.
|
||||||
|
History,
|
||||||
/// One title, by [`pstr_core::library::Title::key`].
|
/// One title, by [`pstr_core::library::Title::key`].
|
||||||
Title(String),
|
Title(String),
|
||||||
Shares,
|
Shares,
|
||||||
@@ -91,6 +94,15 @@ pub enum Page {
|
|||||||
Player,
|
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.
|
/// Something a click asked for, applied once the frame is drawn.
|
||||||
pub enum Action {
|
pub enum Action {
|
||||||
Goto(Page),
|
Goto(Page),
|
||||||
@@ -177,6 +189,12 @@ pub enum Action {
|
|||||||
share_id: String,
|
share_id: String,
|
||||||
link_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.
|
/// Put watch states back as they were — what Undo does.
|
||||||
RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>),
|
RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>),
|
||||||
ClearStreamCache,
|
ClearStreamCache,
|
||||||
@@ -293,6 +311,8 @@ pub struct LibraryView {
|
|||||||
built_with: (Filter, Sort),
|
built_with: (Filter, Sort),
|
||||||
/// Indices into [`Library::titles`].
|
/// Indices into [`Library::titles`].
|
||||||
pub matches: Vec<usize>,
|
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.
|
/// The "Continue watching" shelf, most recent first, as indices.
|
||||||
pub resumable: Vec<usize>,
|
pub resumable: Vec<usize>,
|
||||||
/// The library changed under the lists.
|
/// The library changed under the lists.
|
||||||
@@ -366,6 +386,7 @@ impl LibraryView {
|
|||||||
self.matches = matches.into_iter().filter_map(index_of).collect();
|
self.matches = matches.into_iter().filter_map(index_of).collect();
|
||||||
self.built_with = (self.filter, self.sort);
|
self.built_with = (self.filter, self.sort);
|
||||||
if self.stale {
|
if self.stale {
|
||||||
|
self.history = ui::history::entries(library);
|
||||||
self.resumable = library
|
self.resumable = library
|
||||||
.continue_watching()
|
.continue_watching()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1053,6 +1074,39 @@ impl App {
|
|||||||
self.engine.set_playback_prefs(prefs, commit);
|
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) {
|
fn apply(&mut self, ctx: &egui::Context, action: Action) {
|
||||||
match action {
|
match action {
|
||||||
Action::Goto(page) => self.page = page,
|
Action::Goto(page) => self.page = page,
|
||||||
@@ -1318,27 +1372,10 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Action::ForgetPosition { share_id, link_id } => {
|
Action::ForgetPosition { share_id, link_id } => {
|
||||||
let found = self.library.titles.iter().find_map(|title| {
|
self.forget_watch(ctx, share_id, link_id, "Continue watching");
|
||||||
let episode = title.episodes().find(|episode| {
|
}
|
||||||
episode.node.share_id == share_id && episode.node.link_id == link_id
|
Action::RemoveFromHistory { share_id, link_id } => {
|
||||||
})?;
|
self.forget_watch(ctx, share_id, link_id, "history");
|
||||||
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)]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Action::RestoreWatch(states) => {
|
Action::RestoreWatch(states) => {
|
||||||
for (share_id, link_id, state) in states {
|
for (share_id, link_id, state) in states {
|
||||||
@@ -1459,12 +1496,13 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
format!("Downloads ({active})")
|
format!("Downloads ({active})")
|
||||||
};
|
};
|
||||||
let pages = [Page::Library, Page::Shares, Page::Downloads, Page::Settings];
|
let pages = NAV;
|
||||||
let clicked = ui::tabs(
|
let clicked = ui::tabs(
|
||||||
ui,
|
ui,
|
||||||
ui.id().with("nav"),
|
ui.id().with("nav"),
|
||||||
&[
|
&[
|
||||||
("Library", on_library),
|
("Library", on_library),
|
||||||
|
("History", self.page == Page::History),
|
||||||
("Shares", self.page == Page::Shares),
|
("Shares", self.page == Page::Shares),
|
||||||
(&downloads, self.page == Page::Downloads),
|
(&downloads, self.page == Page::Downloads),
|
||||||
("Settings", self.page == Page::Settings),
|
("Settings", self.page == Page::Settings),
|
||||||
@@ -1776,6 +1814,9 @@ impl eframe::App for App {
|
|||||||
ui::settings::show(ui, settings, prefs, api_key, &mut actions)
|
ui::settings::show(ui, settings, prefs, api_key, &mut actions)
|
||||||
}
|
}
|
||||||
Page::Downloads => ui::downloads::show(ui, downloads, &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.
|
// Drawn above, without any of these panels.
|
||||||
Page::Player => {}
|
Page::Player => {}
|
||||||
}
|
}
|
||||||
@@ -2008,13 +2049,14 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec<Action>)
|
|||||||
(Key::Num2, 1),
|
(Key::Num2, 1),
|
||||||
(Key::Num3, 2),
|
(Key::Num3, 2),
|
||||||
(Key::Num4, 3),
|
(Key::Num4, 3),
|
||||||
|
(Key::Num5, 4),
|
||||||
] {
|
] {
|
||||||
if input.consume_key(Modifiers::COMMAND, key) {
|
if input.consume_key(Modifiers::COMMAND, key) {
|
||||||
tab = Some(index);
|
tab = Some(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if input.consume_key(Modifiers::COMMAND, Key::Comma) {
|
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 link pasted anywhere outside a text field is someone adding
|
||||||
// a share, wherever they happen to be.
|
// 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 {
|
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));
|
actions.push(Action::Goto(page));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
//! applies them after the frame is drawn.
|
//! applies them after the frame is drawn.
|
||||||
|
|
||||||
pub mod downloads;
|
pub mod downloads;
|
||||||
|
pub mod history;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod matcher;
|
pub mod matcher;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
|
|||||||
&[
|
&[
|
||||||
("Ctrl F or /", "Search the library"),
|
("Ctrl F or /", "Search the library"),
|
||||||
("F5 or Ctrl R", "Crawl every share again"),
|
("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 ,", "Settings"),
|
||||||
("Ctrl V", "Paste a share link to add it"),
|
("Ctrl V", "Paste a share link to add it"),
|
||||||
("Tab or arrows", "Move between titles and episodes"),
|
("Tab or arrows", "Move between titles and episodes"),
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ fn episode_row(
|
|||||||
let ui = &mut prepared.content_ui;
|
let ui = &mut prepared.content_ui;
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
ui.horizontal_top(|ui| {
|
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);
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
let controls = 96.0;
|
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
|
/// 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
|
/// 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.
|
/// those must never be one that starts a 1.4 GiB stream instead.
|
||||||
fn still(
|
pub(crate) fn still(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
title: &Title,
|
title: &Title,
|
||||||
episode: &Episode,
|
episode: &Episode,
|
||||||
watched: bool,
|
watched: bool,
|
||||||
|
size: egui::Vec2,
|
||||||
actions: &mut Vec<Action>,
|
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) {
|
if !ui.is_rect_visible(rect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,6 +369,26 @@ impl Library {
|
|||||||
titles
|
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
|
/// Substring match over the display name and every filename under it, so
|
||||||
/// searching for a release group or an episode name finds its title.
|
/// searching for a release group or an episode name finds its title.
|
||||||
pub fn search(&self, query: &str) -> Vec<&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]
|
#[test]
|
||||||
fn groups_episodes_into_seasons() {
|
fn groups_episodes_into_seasons() {
|
||||||
let nodes = vec![
|
let nodes = vec![
|
||||||
|
|||||||
Reference in new issue
Block a user