feat(core,app,android,ios): My List, synced, and New badges with new-episode rows

My List is a list of titles to watch later, kept in my-list.json by
title key (pstr-core::mylist) and synced as one list/<key> register per
title, so an add or a removal on one device reaches every other. A list
file that will not parse is never written over: changing the list is
refused instead. It is a filter, a row on the library, a button on the
banner and the title page, and an entry in each tile's menu.

pstr-core::news reads node_first_seen against each share's first crawl,
so a newly added share does not badge its whole library. A title that
arrived in the last two weeks and was never played is New; a series
being watched counts the unwatched episodes that arrived after it was
last played ("3 new"). They fill the tile badge and the "New episodes"
and "Recently added" rows. shelves::home puts every row in one order
for all three apps, and a shelf tile of an airing show shows a
countdown ("Ep 8 · in 2 d").

The bridge gains TitleRecord.on_list and .badge, set_on_list, the
MyList filter, airing_countdown and SyncRecord.list_changed.
This commit is contained in:
nvrl committed 2026-10-30 22:15:51 +01:00
1 parent cd9e8eea81
commit f66faf6b75
31 files changed
+1142 -70

No files matched your search

+79 -17
View File
@@ -13,6 +13,7 @@ use pstr_core::appearance::Appearance;
use pstr_core::config::AppDirs;
use pstr_core::library::Library;
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
use pstr_core::mylist::MyList;
use crate::engine::{
AccountStatus, DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures,
@@ -293,6 +294,11 @@ pub enum Action {
SetShelf(Filter, Sort),
/// Fold each franchise into one tile, or show every title.
SetGrouped(bool),
/// Put a title on My List, or take it off.
SetOnList {
key: String,
on: bool,
},
/// Open or close the list of keyboard shortcuts.
ToggleShortcuts,
/// One page back: a title to the library, the library to nowhere.
@@ -410,6 +416,10 @@ pub struct LibraryView {
pub history: Vec<ui::history::Entry>,
/// The "Continue watching" shelf, most recent first, as indices.
pub resumable: Vec<usize>,
/// What each tile says about what arrived — `New`, `3 new` — by key.
pub badges: HashMap<String, String>,
/// The keys on My List.
pub listed: std::collections::HashSet<String>,
/// The library or what is known about it changed under the lists.
stale: bool,
/// Only watch state changed: the shelves and franchises still stand,
@@ -445,6 +455,8 @@ impl Default for LibraryView {
shelves: Vec::new(),
history: Vec::new(),
resumable: Vec::new(),
badges: HashMap::new(),
listed: std::collections::HashSet::new(),
stale: false,
progressed: false,
}
@@ -462,6 +474,10 @@ pub struct ViewInputs<'a> {
pub added: &'a HashMap<String, i64>,
/// The files kept on this computer, for the Downloaded filter.
pub offline: &'a std::collections::HashSet<DownloadKey>,
pub list: &'a MyList,
/// When each file first appeared in a crawl, for what is new.
pub first_seen: &'a HashMap<(String, String), i64>,
pub now: i64,
}
/// How many shelves the library shows above the grid.
@@ -481,6 +497,7 @@ impl LibraryView {
if !self.stale && self.progressed && unchanged && !watch_shapes_grid {
self.history = ui::history::entries(library);
self.resumable = Self::resumable(library);
self.badges = Self::badges(library, &inputs);
self.progressed = false;
return;
}
@@ -508,28 +525,44 @@ impl LibraryView {
.enumerate()
.map(|(at, title)| (title.key.as_str(), at))
.collect();
self.shelves = pstr_core::shelves::pick(&library.titles, metadata, SHELVES)
.into_iter()
.map(|shelf| {
let indices = shelf
.keys
.iter()
.filter_map(|key| index.get(key.as_str()).copied())
.collect();
(shelf.label, indices)
})
self.listed = inputs
.list
.entries()
.map(|(key, _)| key.to_owned())
.collect();
self.shelves = pstr_core::shelves::home(
&library.titles,
metadata,
inputs.list,
inputs.first_seen,
inputs.now,
SHELVES,
)
.into_iter()
.map(|shelf| {
let indices = shelf
.keys
.iter()
.filter_map(|key| index.get(key.as_str()).copied())
.collect();
(shelf.label, indices)
})
.collect();
}
let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query)
.into_iter()
.filter(|title| {
self.filter.admits(title, |node| {
inputs.offline.contains(&DownloadKey {
share_id: node.share_id.clone(),
link_id: node.link_id.clone(),
})
})
self.filter.admits(
title,
|node| {
inputs.offline.contains(&DownloadKey {
share_id: node.share_id.clone(),
link_id: node.link_id.clone(),
})
},
inputs.list,
)
})
.collect();
let tiles = pstr_core::browse::arrange(
@@ -564,12 +597,20 @@ impl LibraryView {
if self.stale || self.progressed {
self.history = ui::history::entries(library);
self.resumable = Self::resumable(library);
self.badges = Self::badges(library, &inputs);
}
self.query = inputs.query.to_owned();
self.stale = false;
self.progressed = false;
}
fn badges(library: &Library, inputs: &ViewInputs<'_>) -> HashMap<String, String> {
pstr_core::news::badges(&library.titles, inputs.first_seen, inputs.now)
.into_iter()
.map(|(key, badge)| (key, badge.label()))
.collect()
}
/// The "Continue watching" shelf, as indices into the library.
fn resumable(library: &Library) -> Vec<usize> {
library
@@ -628,6 +669,9 @@ pub struct App {
pub metadata: HashMap<String, MetadataRecord>,
/// When each title last gained a file, by key — for "Recently added".
pub added: HashMap<String, i64>,
/// When each file first appeared in a crawl, for the New badges.
first_seen: HashMap<(String, String), i64>,
my_list: MyList,
/// What they said about the episodes under those titles.
pub episodes: HashMap<String, EpisodeGuide>,
pub settings: MetadataConfig,
@@ -723,6 +767,7 @@ impl App {
let window = crate::window::WindowMemory::new(dirs.window_file());
let notifier = crate::notify::Notifier::new(&dirs.cache);
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
let my_list = engine.my_list();
theme::install_fonts(&cc.egui_ctx);
theme::apply(&cc.egui_ctx, engine.appearance());
cc.egui_ctx.set_zoom_factor(engine.desktop_prefs().zoom());
@@ -752,6 +797,8 @@ impl App {
posters: ImageCache::default(),
metadata: HashMap::new(),
added: HashMap::new(),
first_seen: HashMap::new(),
my_list,
episodes: HashMap::new(),
settings,
api_key: String::new(),
@@ -886,6 +933,11 @@ impl App {
self.connecting = false;
self.note(ctx, format!("could not open the shares: {error}"), true);
}
Event::FirstSeen(first_seen) => self.first_seen = first_seen,
Event::MyList(list) => {
self.my_list = list;
self.view.stale = true;
}
Event::LibraryLoaded(library, added) => {
self.library = library;
self.added = added;
@@ -1685,6 +1737,10 @@ impl App {
self.view.grouped = grouped;
self.remember_arrangement();
}
Action::SetOnList { key, on } => {
self.my_list = self.engine.set_on_list(&key, on);
self.view.stale = true;
}
Action::Back => match self.page {
Page::Title(_) => self.page = Page::Library,
// Back out of a search before out of anything else: it is the
@@ -2391,6 +2447,9 @@ impl eframe::App for App {
names: self.engine.appearance().names,
added: &self.added,
offline: &self.offline_files,
list: &self.my_list,
first_seen: &self.first_seen,
now: crate::engine::unix_now(),
},
);
@@ -2519,7 +2578,10 @@ impl eframe::App for App {
&mut art,
library,
key,
view.franchises.get(key.as_str()).map(Vec::as_slice),
ui::title::Context {
franchise: view.franchises.get(key.as_str()).map(Vec::as_slice),
on_list: view.listed.contains(key.as_str()),
},
ui::title::OfflineView {
downloads,
files: offline_files,
+62
View File
@@ -33,6 +33,7 @@ use pstr_core::catalog::{
use pstr_core::config::AppDirs;
use pstr_core::library::{Library, Title, TitleKind};
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
use pstr_core::mylist::MyList;
use pstr_core::prefs::PlaybackPrefs;
use pstr_core::profile::ProfileChanges;
use pstr_core::proton_drive_rs::ThumbnailType;
@@ -221,6 +222,12 @@ pub enum Event {
/// The catalog was re-read: the library, and when each title last
/// gained a file.
LibraryLoaded(Library, HashMap<String, i64>),
/// When each file first appeared in a crawl, for the New badges; see
/// [`pstr_core::catalog::Catalog::first_seen`]. Sent just before
/// [`Event::LibraryLoaded`].
FirstSeen(HashMap<(String, String), i64>),
/// My List changed on another device.
MyList(MyList),
/// A crawl finished for one share.
Crawled {
share_id: String,
@@ -415,6 +422,9 @@ pub struct Engine {
/// Which theme the window wears. Kept beside the playback preferences for
/// the same reason: it is read from the UI and written to disk off it.
appearance: Arc<Mutex<Appearance>>,
/// My List, read once at launch. `None` when the file would not parse: it
/// is then never written over, and changing the list is refused.
my_list: Arc<Mutex<Option<MyList>>>,
/// Preloaded so clicking Play never runs SQLite on egui's thread.
track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>,
downloads: Arc<Mutex<Downloads>>,
@@ -522,6 +532,10 @@ impl Engine {
Appearance::default()
});
let my_list = pstr_core::mylist::load(&dirs)
.inspect_err(|error| tracing::warn!("read My List: {error}"))
.ok();
// The TMDB key, which every provider looks for, comes out of the OS
// credential store, and that can be a D-Bus round trip to a keyring
// that has to unlock first — not something to hold the first frame
@@ -560,6 +574,7 @@ impl Engine {
enrichment_settled: Arc::new(tokio::sync::watch::channel(!deferred).0),
prefs: Arc::new(Mutex::new(prefs)),
appearance: Arc::new(Mutex::new(appearance)),
my_list: Arc::new(Mutex::new(my_list)),
track_prefs: Arc::new(Mutex::new(track_prefs)),
downloads: Arc::new(Mutex::new(Downloads::default())),
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
@@ -624,6 +639,43 @@ impl Engine {
*self.appearance.lock()
}
/// My List as it stands.
pub fn my_list(&self) -> MyList {
self.my_list.lock().clone().unwrap_or_default()
}
/// Put a title on My List or take it off, write the list, and return it
/// as it now is.
pub fn set_on_list(&self, key: &str, on: bool) -> MyList {
let mut guard = self.my_list.lock();
let Some(list) = guard.as_mut() else {
self.emit(Event::Error(
"My List could not be read, so it was left as it is.".to_owned(),
));
return MyList::default();
};
if on {
list.add(key, unix_now());
} else {
list.remove(key);
}
let saved = list.clone();
drop(guard);
let engine = self.clone();
self.runtime.spawn_blocking(move || {
// Whatever the list is by now, under the lock: two clicks in a row
// must not land on disk in the wrong order.
let guard = engine.my_list.lock();
if let Some(list) = guard.as_ref()
&& let Err(error) = pstr_core::mylist::save(&engine.dirs, list)
{
drop(guard);
engine.fail("save My List", error);
}
});
saved
}
/// Change the theme and write it.
///
/// Always committed, unlike the volume: a theme is picked with a click and
@@ -1024,6 +1076,15 @@ impl Engine {
Err(error) => tracing::warn!("reread the metadata settings: {error}"),
}
}
if profile.list {
match pstr_core::mylist::load(&self.dirs) {
Ok(list) => {
*self.my_list.lock() = Some(list.clone());
self.emit(Event::MyList(list));
}
Err(error) => tracing::warn!("reread My List: {error}"),
}
}
if profile.titles > 0 {
match self.catalog.lock().all_title_track_prefs() {
Ok(prefs) => *self.track_prefs.lock() = prefs,
@@ -1289,6 +1350,7 @@ impl Engine {
let library = Library::build(files, &watch);
let targets = download_targets(&library);
let added = library.added_at(&first_seen);
engine.emit(Event::FirstSeen(first_seen));
engine.emit(Event::LibraryLoaded(library, added));
engine.refresh_offline(Some(targets));
}
+68 -12
View File
@@ -1,5 +1,7 @@
//! The library page: what to watch, as a wall of stills.
use std::collections::HashSet;
use pstr_core::appearance::Tiles;
use pstr_core::library::{Library, Title, TitleKind};
@@ -66,7 +68,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
.auto_shrink([false, false])
.show(ui, |ui| {
if !featured.is_empty() {
hero(ui, art, &featured, actions);
hero(ui, art, &featured, &view.listed, actions);
ui.add_space(theme::space::L);
}
if !searching && offer_metadata {
@@ -77,7 +79,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
let resumable = pick(&view.resumable);
if !resumable.is_empty() {
ui::section(ui, "Continue watching");
continue_row(ui, art, &resumable, actions);
continue_row(ui, art, &resumable, view, actions);
ui.add_space(theme::space::XL);
}
// Only over the whole library: under a filter, a shelf of
@@ -86,7 +88,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
if view.filter == Filter::All {
for (label, indices) in &view.shelves {
ui::section(ui, label);
shelf_row(ui, art, label, &pick(indices), tiles, actions);
shelf_row(ui, art, label, &pick(indices), view, tiles, actions);
ui.add_space(theme::space::XL);
}
}
@@ -201,7 +203,13 @@ struct Turn {
///
/// Edge to edge rather than inside the page's margin, and square: a banner
/// in the margin with rounded corners read as one more card.
fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
fn hero(
ui: &mut egui::Ui,
art: &mut Art<'_>,
titles: &[&Title],
listed: &HashSet<String>,
actions: &mut Vec<Action>,
) {
let height = (ui.ctx().content_rect().height() * 0.5).clamp(280.0, 520.0);
let (column, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), height),
@@ -315,7 +323,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
.layout(egui::Layout::bottom_up(egui::Align::Min)),
|ui| {
ui.multiply_opacity(arriving);
hero_text(ui, art, title, actions);
hero_text(ui, art, title, listed.contains(&title.key), actions);
},
);
@@ -413,7 +421,13 @@ fn backdrop(
/// What the banner says about one title, from the bottom up: how far into it
/// the viewer is, the buttons, the synopsis, the facts and the name.
fn hero_text(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
fn hero_text(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
on_list: bool,
actions: &mut Vec<Action>,
) {
let found = art
.metadata
.get(&title.key)
@@ -448,6 +462,8 @@ fn hero_text(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut
if ui::tonal_button(ui, &more).clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
ui.add_space(theme::space::S);
ui::list_button(ui, &title.key, on_list, actions);
});
ui.add_space(theme::space::L);
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
@@ -614,10 +630,12 @@ fn shelf_row(
art: &mut Art<'_>,
label: &str,
titles: &[&Title],
view: &LibraryView,
tiles: Tiles,
actions: &mut Vec<Action>,
) {
let (width, aspect) = tile_shape(tiles);
let now = crate::engine::unix_now();
ui::shelf(ui, ("shelf", label), |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
@@ -633,12 +651,16 @@ fn shelf_row(
name: art.name(title),
subtitle: subtitle(title),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: None,
badge: view
.badges
.get(&title.key)
.cloned()
.or_else(|| countdown(art, title, now)),
width,
aspect,
},
);
tile_menu(&response, title, false, actions);
tile_menu(&response, title, false, view, actions);
if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
@@ -647,6 +669,19 @@ fn shelf_row(
});
}
/// `Ep 8 · in 2 d` on a show whose next episode is still to air.
fn countdown(art: &Art<'_>, title: &Title, now: i64) -> Option<String> {
let (episode, at) = art
.metadata
.get(&title.key)?
.metadata
.as_ref()?
.details
.as_ref()?
.next_airing(now)?;
Some(pstr_core::news::countdown(episode, at, now))
}
/// The minimum width and the picture's shape of a tile.
fn tile_shape(tiles: Tiles) -> (f32, f32) {
match tiles {
@@ -660,6 +695,7 @@ fn continue_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
titles: &[&Title],
view: &LibraryView,
actions: &mut Vec<Action>,
) {
ui::shelf(ui, "continue", |ui| {
@@ -693,7 +729,7 @@ fn continue_row(
aspect: theme::CARD_ASPECT,
},
);
tile_menu(&response, title, true, actions);
tile_menu(&response, title, true, view, actions);
if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
@@ -758,13 +794,18 @@ fn grid(
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}")),
// folded franchise says how much is under it, unless
// something new arrived in it.
badge: view
.badges
.get(&title.key)
.cloned()
.or_else(|| folded.map(|more| format!("+{more}"))),
width: grid.width,
aspect,
},
);
tile_menu(&response, title, false, actions);
tile_menu(&response, title, false, view, actions);
if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
@@ -783,6 +824,7 @@ fn tile_menu(
response: &egui::Response,
title: &Title,
continuing: bool,
view: &LibraryView,
actions: &mut Vec<Action>,
) {
response.context_menu(|ui| {
@@ -799,6 +841,20 @@ fn tile_menu(
if ui.button("Open").clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
let on_list = view.listed.contains(&title.key);
if ui
.button(if on_list {
"Remove from My List"
} else {
"Add to My List"
})
.clicked()
{
actions.push(Action::SetOnList {
key: title.key.clone(),
on: !on_list,
});
}
ui.separator();
let all_watched = title.watched_count() == title.episode_count();
+22
View File
@@ -23,6 +23,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
use pstr_core::library::{Episode, Title};
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord, TitleNames};
use crate::app::Action;
use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
use crate::theme;
use widgets::Named;
@@ -375,6 +376,27 @@ pub fn tonal_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
filled(ui, text, Fill::Tonal, Vec2::new(0.0, 0.0))
}
/// My List's button: `+ My List` to put the title on it, `✓ My List` once it
/// is, which takes it off again.
pub fn list_button(ui: &mut egui::Ui, key: &str, on_list: bool, actions: &mut Vec<Action>) {
let label = if on_list {
format!("{} My List", egui_phosphor::regular::CHECK)
} else {
format!("{} My List", egui_phosphor::regular::PLUS)
};
let response = tonal_button(ui, &label).on_hover_text(if on_list {
"On My List. Click to take it off"
} else {
"Put it on My List, to find it again on every device"
});
if response.clicked() {
actions.push(Action::SetOnList {
key: key.to_owned(),
on: !on_list,
});
}
}
/// A button laid over the picture: dark glass and white ink, whatever the
/// theme, because what is under it is a video frame and not the page.
pub fn glass_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
+12 -2
View File
@@ -16,15 +16,23 @@ pub struct OfflineView<'a> {
pub files: &'a std::collections::HashSet<DownloadKey>,
}
/// Where the title stands beyond its own files.
pub struct Context<'a> {
/// The keys of its whole franchise in release order, if it has one.
pub franchise: Option<&'a [String]>,
pub on_list: bool,
}
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
key: &str,
franchise: Option<&[String]>,
context: Context<'_>,
offline: OfflineView<'_>,
actions: &mut Vec<Action>,
) {
let Context { franchise, on_list } = context;
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.
@@ -40,7 +48,7 @@ pub fn show(
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
header(ui, art, title, &offline, actions);
header(ui, art, title, on_list, &offline, actions);
ui.add_space(theme::space::XL);
if let Some(keys) = franchise {
@@ -289,6 +297,7 @@ fn header(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
on_list: bool,
offline: &OfflineView<'_>,
actions: &mut Vec<Action>,
) {
@@ -425,6 +434,7 @@ fn header(
None,
)));
}
ui::list_button(ui, &title.key, on_list, actions);
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 {