bug fixes, windows build

This commit is contained in:
nvrl committed 2026-08-03 09:09:40 +02:00
1 parent a1b345a029
commit 14c48428a3
29 files changed
+2500 -156

No files matched your search

Generated
+97 -6
View File
@@ -231,7 +231,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
"x11rb",
]
@@ -2841,9 +2841,13 @@ version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"dbus-secret-service",
"log",
"secret-service",
"security-framework 2.11.1",
"security-framework 3.7.0",
"windows-sys 0.60.2",
"zeroize",
]
@@ -4032,7 +4036,7 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proton-drive-rs"
version = "0.3.3"
version = "0.4.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -4057,7 +4061,7 @@ dependencies = [
[[package]]
name = "proton-sdk"
version = "0.3.3"
version = "0.4.0"
dependencies = [
"aes-gcm 0.11.0",
"async-trait",
@@ -4631,7 +4635,7 @@ dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 3.7.0",
]
[[package]]
@@ -4659,7 +4663,7 @@ dependencies = [
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
@@ -4759,6 +4763,19 @@ dependencies = [
"zbus",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.13.1",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -6209,6 +6226,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -6257,13 +6283,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
@@ -6282,6 +6325,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -6300,6 +6349,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -6318,12 +6373,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -6342,6 +6409,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -6360,6 +6433,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -6378,6 +6457,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -6396,6 +6481,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
+11 -3
View File
@@ -27,8 +27,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
opt-level = 2
[workspace.dependencies]
proton-sdk = { version = "0.3.3" }
proton-drive-rs = { version = "0.3.3" }
proton-sdk = { version = "0.4.0" }
proton-drive-rs = { version = "0.4.0" }
# Internal crates.
pstr-core = { path = "crates/pstr-core" }
@@ -56,7 +56,15 @@ rusqlite = { version = "0.32", features = ["bundled"] }
# The share URL fragment and the custom password are both secrets; they go in
# the OS credential store (Secret Service on Linux, Credential Manager on
# Windows), never in the config file next to them.
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
#
# No platform features here on purpose. `keyring` selects its keystore by
# `cfg(all(target_os = ..., feature = ...))`, and with the matching feature off
# it falls back to `mock` — an in-memory store built fresh per `Entry`, so
# `set_password` succeeds, the next `Entry::new(..).get_password()` returns
# `NoEntry`, and nothing warns. Every consuming crate therefore enables the
# store for its target in a `[target.'cfg(...)'.dependencies]` block; see
# `crates/pstr-core/Cargo.toml`.
keyring = { version = "3" }
clap = { version = "4", features = ["derive"] }
# The UI. `glow` rather than `wgpu` because step two of the player hands mpv an
# `MPV_RENDER_API_TYPE_OPENGL` context, and that has to be the same context
+1
View File
@@ -57,6 +57,7 @@ against the SDK's own range download.
| Player | Embedded libmpv: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. |
| Transcoding | None. mpv demuxes and decodes natively. |
| UI | egui/eframe on glow; mpv renders into the same GL context. |
| Themes | The shipped near-black palette plus Catppuccin Latte, Frappé, Macchiato and Mocha, each with a choice of eight accents — pink and sky among them — drawn as a gradient unless you turn that off. Picked on the Shares page, applied without a restart. |
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. |
| Platforms | Linux and Windows. |
+181 -22
View File
@@ -9,6 +9,7 @@ use std::collections::HashMap;
use std::sync::mpsc::Receiver;
use pstr_core::Share;
use pstr_core::appearance::Appearance;
use pstr_core::config::AppDirs;
use pstr_core::library::Library;
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord};
@@ -128,10 +129,21 @@ pub enum Action {
MatchTitles {
force: bool,
},
/// Open the hand-matching search for one title, seeded with its own name.
OpenMatcher(String),
CloseMatcher,
/// Ask the provider what the text in the box might be.
SearchMatches,
/// Pin the open title to this entry.
ChooseMatch(Box<pstr_core::metadata::TitleMetadata>),
/// Forget what is stored for a title, so it is matched from scratch again.
ForgetMatch(String),
/// Play the file before or after the one playing, within its title.
PlayAdjacent(Adjacent),
/// Start or stop the next episode playing on its own at the end of one.
SetAutoplay(bool),
/// Repaint the window in a different palette.
SetAppearance(Appearance),
/// In or out of fullscreen, from the player page.
ToggleFullscreen,
/// Stop watching, keep playing: back to the page this film came from, with
@@ -178,6 +190,52 @@ pub struct Status {
pub at: f64,
}
/// Picking a title's entry by hand.
///
/// The escape hatch from the matcher, which is deliberately unwilling to guess:
/// [`pstr_meta::matching::MATCH_FLOOR`] is set where a wrong poster is worse
/// than none, and the cost of that is a handful of titles that match nothing.
/// The `Fate/stay night [Heaven's Feel]` films are the standing example — three
/// films in one folder, filed by AniList as three separate entries, so there is
/// no answer for the scorer to find and only the viewer knows which one the
/// folder means.
pub struct Matcher {
/// The title being matched, by [`pstr_core::library::Title::key`].
pub title_key: String,
/// Its name as the library has it, for the dialog's own heading.
pub title_name: String,
pub kind: pstr_core::library::TitleKind,
/// What is in the search box. Seeded with the library's name for the title,
/// which is the thing that failed — so it is the right starting point to
/// edit rather than to re-send.
pub query: String,
pub results: Vec<pstr_core::metadata::TitleMetadata>,
/// A request is out. The box stays usable; only the button is held.
pub searching: bool,
/// Whether a search has come back yet, so an empty list can read as "nothing
/// found" rather than "nothing asked".
pub asked: bool,
pub error: Option<String>,
/// Set for the frame the dialog opens, to put the caret in the box.
pub focus: bool,
}
impl Matcher {
pub fn new(title: &pstr_core::library::Title) -> Self {
Self {
title_key: title.key.clone(),
title_name: title.name.clone(),
kind: title.kind,
query: title.name.clone(),
results: Vec::new(),
searching: false,
asked: false,
error: None,
focus: true,
}
}
}
/// The share the viewer is typing in.
#[derive(Default)]
pub struct ShareForm {
@@ -206,6 +264,8 @@ pub struct App {
/// it goes straight to the credential store on save.
pub api_key: String,
pub matching: bool,
/// The hand-matching dialog, while it is open.
pub matcher: Option<Matcher>,
pub search: String,
pub form: ShareForm,
pub playback: Option<Playback>,
@@ -229,9 +289,11 @@ impl App {
runtime: std::sync::Arc<tokio::runtime::Runtime>,
dirs: AppDirs,
) -> anyhow::Result<Self> {
theme::apply(&cc.egui_ctx);
// After the engine, not before: the engine is what reads the stored
// theme, and a window that paints one frame in the default palette
// before switching is a window that flashes on every launch.
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
theme::apply(&cc.egui_ctx, engine.appearance());
// Paint from the catalog immediately; the network catches up. A library
// that was crawled yesterday is on screen before the shares open.
@@ -254,6 +316,7 @@ impl App {
settings,
api_key: String::new(),
matching: false,
matcher: None,
search: String::new(),
form: ShareForm::default(),
playback: None,
@@ -345,6 +408,28 @@ impl App {
self.note(ctx, text, failed > 0);
}
Event::MatchFinished => self.matching = false,
// Both of these are addressed to a dialog, and the viewer may
// have closed it or opened another one while the request was
// out — so an answer for a title that is not the open one is
// dropped rather than shown under the wrong heading.
Event::MatchOptions { title_key, options } => {
if let Some(matcher) = &mut self.matcher
&& matcher.title_key == title_key
{
matcher.results = options;
matcher.searching = false;
matcher.asked = true;
matcher.error = None;
}
}
Event::MatchSearchFailed { title_key, error } => {
if let Some(matcher) = &mut self.matcher
&& matcher.title_key == title_key
{
matcher.searching = false;
matcher.error = Some(error);
}
}
Event::PlaybackReady { target, stream } => {
self.opening = None;
// Dropped before the new one is built: two mpv cores would
@@ -605,6 +690,43 @@ impl App {
self.engine.match_titles(self.library.titles.clone(), force);
}
}
Action::OpenMatcher(key) => {
self.matcher = self.library.get(&key).map(Matcher::new);
// The library's own name is what the automatic search already
// failed on, so the first search is the viewer's to press —
// except that pressing it unchanged is exactly what someone
// wants when the *scorer* was the problem rather than the name.
// So it is sent, and the box stays theirs to edit.
if self.matcher.is_some() {
self.apply(ctx, Action::SearchMatches);
}
}
Action::CloseMatcher => self.matcher = None,
Action::SearchMatches => {
if let Some(matcher) = &mut self.matcher {
matcher.searching = true;
matcher.error = None;
self.engine.search_matches(
matcher.title_key.clone(),
matcher.query.clone(),
matcher.kind,
);
}
}
Action::ChooseMatch(found) => {
if let Some(title) = self
.matcher
.as_ref()
.and_then(|matcher| self.library.get(&matcher.title_key))
{
self.engine.choose_match(title.clone(), *found);
}
self.matcher = None;
}
Action::ForgetMatch(key) => {
self.engine.forget_match(key);
self.matcher = None;
}
Action::ToggleFullscreen => {
self.fullscreen = !self.fullscreen;
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen));
@@ -679,6 +801,10 @@ impl App {
prefs.autoplay_next = autoplay;
self.engine.set_playback_prefs(prefs, true);
}
Action::SetAppearance(appearance) => {
self.engine.set_appearance(appearance);
theme::apply(ctx, appearance);
}
Action::SetVolume { volume, commit } => self.set_volume(volume, commit),
Action::ToggleMute => {
let mut prefs = self.engine.playback_prefs();
@@ -734,18 +860,15 @@ impl App {
egui::RichText::new("proton-stream")
.size(18.0)
.strong()
.color(theme::ACCENT),
.color(theme::accent()),
);
ui.add_space(12.0);
let on_library = matches!(self.page, Page::Library | Page::Title(_));
if ui.selectable_label(on_library, "Library").clicked() {
if ui::tab(ui, on_library, "Library").clicked() {
actions.push(Action::Goto(Page::Library));
}
if ui
.selectable_label(self.page == Page::Shares, "Shares")
.clicked()
{
if ui::tab(ui, self.page == Page::Shares, "Shares").clicked() {
actions.push(Action::Goto(Page::Shares));
}
@@ -845,17 +968,35 @@ impl eframe::App for App {
{
let mut search = std::mem::take(&mut self.search);
let neighbours = self.neighbours();
let autoplay = self.engine.playback_prefs().autoplay_next;
let prefs = ui::shares::Prefs {
autoplay: self.engine.playback_prefs().autoplay_next,
appearance: self.engine.appearance(),
};
const NAV_MARGIN: egui::Vec2 = egui::vec2(14.0, 10.0);
egui::Panel::top("nav")
.resizable(false)
.frame(
egui::Frame::new()
.fill(theme::SURFACE)
.inner_margin(egui::Margin::symmetric(14, 10)),
)
// No fill: the bar is a gradient, and a `Frame` only takes a
// colour. It is painted below, into a shape reserved before the
// contents are laid out — which is the only point at which the
// height they came to is known.
.frame(egui::Frame::NONE.inner_margin(egui::Margin::symmetric(
NAV_MARGIN.x as i8,
NAV_MARGIN.y as i8,
)))
.show(ui, |ui| {
let background = ui.painter().add(egui::Shape::Noop);
self.navigation(ui, &mut actions, &mut search);
// Full width from the space the panel was given, height from
// the space its contents took.
let content = ui.min_rect();
let bar = egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left(), content.top()),
egui::pos2(ui.max_rect().right(), content.bottom()),
)
.expand2(NAV_MARGIN);
ui.painter()
.set(background, theme::bar_shape(ui.ctx(), bar));
});
self.search = search;
@@ -872,6 +1013,7 @@ impl eframe::App for App {
api_key,
search,
form,
matcher,
playback,
opening,
status,
@@ -883,7 +1025,7 @@ impl eframe::App for App {
.resizable(false)
.frame(
egui::Frame::new()
.fill(theme::SURFACE)
.fill(theme::surface())
.inner_margin(egui::Margin::symmetric(16, 10)),
)
.show(ui, |ui| {
@@ -904,14 +1046,14 @@ impl eframe::App for App {
.resizable(false)
.frame(
egui::Frame::new()
.fill(theme::BACKGROUND)
.fill(theme::background())
.inner_margin(egui::Margin::symmetric(16, 6)),
)
.show(ui, |ui| {
let colour = if line.error {
theme::DANGER
theme::danger()
} else {
theme::MUTED
theme::muted()
};
ui.label(egui::RichText::new(&line.text).size(12.0).color(colour));
});
@@ -926,14 +1068,17 @@ impl eframe::App for App {
egui::CentralPanel::default()
.frame(
egui::Frame::new()
.fill(theme::BACKGROUND)
.fill(theme::background())
.inner_margin(egui::Margin::symmetric(18, 12)),
)
.show(ui, |ui| {
// Reborrowed rather than moved: the matching dialog below is
// drawn from the same caches, and a moved `&mut` would leave
// nothing to draw it with.
let mut art = ui::Art {
engine,
thumbs,
posters,
thumbs: &mut *thumbs,
posters: &mut *posters,
metadata,
episodes,
};
@@ -949,7 +1094,7 @@ impl eframe::App for App {
shares,
form,
settings,
autoplay,
prefs,
api_key,
&mut actions,
),
@@ -957,6 +1102,20 @@ impl eframe::App for App {
Page::Player => {}
}
});
// Over everything, and after it: a modal takes the input the page
// under it would otherwise get, and it can only do that for widgets
// laid out after it claims the layer.
if let Some(open) = matcher {
let mut art = ui::Art {
engine,
thumbs,
posters,
metadata,
episodes,
};
ui::matcher::show(ctx, open, &mut art, settings.provider, &mut actions);
}
}
for action in actions {
+144 -2
View File
@@ -23,9 +23,10 @@ use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use parking_lot::Mutex;
use pstr_core::appearance::Appearance;
use pstr_core::catalog::{Catalog, CatalogNode, WatchState, build_rows};
use pstr_core::config::AppDirs;
use pstr_core::library::{Library, Title};
use pstr_core::library::{Library, Title, TitleKind};
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
use pstr_core::prefs::PlaybackPrefs;
use pstr_core::proton_drive_rs::ThumbnailType;
@@ -85,6 +86,16 @@ pub enum Event {
},
/// Every requested match is done.
MatchFinished,
/// What the provider answered a hand search with, unscored and in its own
/// order. `title_key` says which title asked, because the viewer can close
/// the search and open another one while a request is still out.
MatchOptions {
title_key: String,
options: Vec<pstr_core::metadata::TitleMetadata>,
},
/// A hand search did not get an answer. Distinct from an empty one: nothing
/// found is a result, and a rate limit is not.
MatchSearchFailed { title_key: String, error: String },
/// A decoded piece of provider artwork, keyed by title key.
Poster {
key: String,
@@ -158,6 +169,9 @@ pub struct Engine {
/// because a new player is built from them, and the player is started from
/// a background event.
prefs: Arc<Mutex<PlaybackPrefs>>,
/// 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>>,
events: Sender<Event>,
ctx: egui::Context,
}
@@ -221,6 +235,14 @@ impl Engine {
PlaybackPrefs::default()
});
// And the same again for the theme, which is the least load-bearing
// file of the three: an unreadable one costs the viewer their colours
// until they pick them again.
let appearance = pstr_core::appearance::load(&dirs).unwrap_or_else(|error| {
tracing::warn!("read the appearance: {error}");
Appearance::default()
});
let engine = Self {
runtime,
store: Arc::new(ShareStore::new(dirs.clone())),
@@ -232,6 +254,7 @@ impl Engine {
lookups: Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)),
metadata: Arc::new(Mutex::new(Enrichment::build(config))),
prefs: Arc::new(Mutex::new(prefs)),
appearance: Arc::new(Mutex::new(appearance)),
events,
ctx,
};
@@ -271,6 +294,27 @@ impl Engine {
});
}
/// Which theme the window wears.
pub fn appearance(&self) -> Appearance {
*self.appearance.lock()
}
/// Change the theme and write it.
///
/// Always committed, unlike the volume: a theme is picked with a click and
/// there is no drag to coalesce.
pub fn set_appearance(&self, appearance: Appearance) {
*self.appearance.lock() = appearance;
let engine = self.clone();
self.runtime.spawn_blocking(move || {
if let Err(error) = pstr_core::appearance::save(&engine.dirs, &appearance) {
// Not `fail`, for the same reason the playback preferences are
// not: the change happened, and only the remembering did not.
tracing::warn!("save the appearance: {error}");
}
});
}
/// Send an event and wake the UI. A closed channel means the window is
/// gone, which is not an error worth propagating anywhere.
pub(crate) fn emit(&self, event: Event) {
@@ -745,7 +789,13 @@ impl Engine {
let mut pending: Vec<Work> = Vec::new();
for title in titles {
let record = stored.get(&title.key);
if force || !pstr_meta::service::is_usable(record, provider) {
// A hand-picked entry is never searched for again, not even by
// "match again" — that button means "the automatic answers are
// wrong", and re-deciding the one title the viewer already fixed by
// hand is the opposite of what they asked for. Its episode list is
// still fetched below if it is missing.
let pinned = record.is_some_and(|record| record.manual && record.provider == provider);
if !pinned && (force || !pstr_meta::service::is_usable(record, provider)) {
pending.push(Work::Match(title));
continue;
}
@@ -848,6 +898,98 @@ impl Engine {
self.load_metadata();
}
/// Ask the provider what `term` might be, for a viewer choosing by hand.
///
/// Unscored and unfiltered — see [`MetadataService::search`]. Nothing is
/// stored: this is a look, and only [`Engine::choose_match`] writes.
pub fn search_matches(&self, title_key: String, term: String, kind: TitleKind) {
let Some(service) = self.metadata.lock().service.clone() else {
let config = self.metadata_config();
return self.emit(Event::MatchSearchFailed {
title_key,
error: if !config.enabled {
"turn on metadata enrichment first".into()
} else {
format!("{} needs an API key", config.provider.label())
},
});
};
let engine = self.clone();
let permits = Arc::clone(&self.lookups);
self.runtime.spawn(async move {
// The same permit the batch run takes: a viewer typing in the search
// box while a match run is going must not be what earns the 429.
let Ok(_permit) = permits.acquire().await else {
return;
};
match service.search(&term, kind).await {
Ok(options) => engine.emit(Event::MatchOptions { title_key, options }),
Err(error) => {
tracing::warn!("search {term:?}: {error}");
engine.emit(Event::MatchSearchFailed {
title_key,
error: error.to_string(),
});
}
}
});
}
/// Pin one title to the entry the viewer picked, and take its episodes.
///
/// Stored as [`MetadataRecord::manual`], so it outlives every TTL and
/// survives "match again". The episode list is fetched here rather than left
/// to the next match run, because the point of choosing an entry by hand is
/// usually that its episode names were wrong too.
pub fn choose_match(&self, title: Title, found: pstr_core::metadata::TitleMetadata) {
let Some(service) = self.metadata.lock().service.clone() else {
return self.emit(Event::Error("turn on metadata enrichment first".into()));
};
let engine = self.clone();
self.runtime.spawn(async move {
let name = found.name.clone();
let record = service.chosen(title.key.clone(), found.clone());
if let Err(error) = engine.catalog.lock().set_metadata(&record) {
return engine.fail("store the match", error);
}
let episodes = episodes_of(&service, &title, &found).await;
if !episodes.is_empty() {
let stored = engine.catalog.lock().set_episode_metadata(
&title.key,
service.provider(),
now(),
&episodes,
);
if let Err(error) = stored {
tracing::warn!("store episodes for {}: {error}", title.key);
}
}
engine.emit(Event::Status(format!("{} is now {name}", title.name)));
engine.load_metadata();
});
}
/// Drop what is stored for one title, so it is matched from scratch again.
///
/// The undo for [`Engine::choose_match`], and the repair for a bad automatic
/// match: the row goes entirely rather than becoming a stored miss, which is
/// what makes the next match run treat the title as one it has never asked
/// about.
pub fn forget_match(&self, title_key: String) {
let engine = self.clone();
self.runtime.spawn_blocking(move || {
if let Err(error) = engine.catalog.lock().forget_metadata(&title_key) {
return engine.fail("forget the match", error);
}
engine.emit(Event::Status("match cleared".into()));
engine.load_metadata();
});
}
/// Fetch, decode and post one title's artwork.
///
/// Cached on disk under a hash of the URL, so a provider that moves its CDN
+8
View File
@@ -4,6 +4,14 @@
//! logging, the tokio runtime, the window, and the app inside it. See
//! [`pstr_app`] for how the three threads divide the work.
// A GUI binary linked against the Windows *console* subsystem gets a console
// allocated for it, so launching from the Start menu flashes up a cmd window
// that lives as long as the app. Only release builds are switched: with the
// windows subsystem there is no stdout for `tracing_subscriber` to write to, and
// a developer running `cargo run` on Windows wants the log more than the tidy
// window. `pstr-cli` stays on the console subsystem, which is correct for it.
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
use anyhow::Context;
use pstr_core::config::AppDirs;
+703 -34
View File
@@ -1,21 +1,33 @@
//! The look: one dark palette, applied once at startup.
//! The look: one palette, chosen once and swapped at runtime.
//!
//! Deliberately dark and low-contrast around the edges — this is a window full
//! of video stills, and every bit of chrome brightness competes with them. The
//! accent is Proton's purple, which is also the only strong colour in the app,
//! so anything wearing it is something to click.
//! accent is the only strong colour in the app, so anything wearing it is
//! something to click.
//!
//! ## Why the palette is a function call and not a constant
//!
//! It used to be a wall of `const`s, which is what a theme wants to be right up
//! until there is more than one of them. The colours now come from
//! [`palette()`], which reads a process-wide palette that [`apply`] writes.
//! Every alternative was worse: threading a `&Palette` through the fifty call
//! sites in `ui/` would have put a lifetime on every drawing signature in the
//! app, and stashing it in `egui::Context` would have made the colour of a
//! label depend on which `Ui` happened to be in scope. There is one window and
//! one palette in it; a global is what that is.
//!
//! The palette is only ever written by [`apply`], from the UI thread, between
//! frames.
use egui::{Color32, CornerRadius, Stroke, Visuals};
use std::collections::BTreeMap;
use std::sync::Arc;
pub const BACKGROUND: Color32 = Color32::from_rgb(0x0e, 0x0e, 0x12);
pub const SURFACE: Color32 = Color32::from_rgb(0x17, 0x17, 0x1d);
pub const CARD: Color32 = Color32::from_rgb(0x1e, 0x1e, 0x26);
pub const CARD_HOVER: Color32 = Color32::from_rgb(0x2a, 0x2a, 0x35);
pub const TEXT: Color32 = Color32::from_rgb(0xea, 0xea, 0xf0);
pub const MUTED: Color32 = Color32::from_rgb(0x8e, 0x8e, 0x9c);
pub const ACCENT: Color32 = Color32::from_rgb(0x7d, 0x4d, 0xff);
pub const ACCENT_DIM: Color32 = Color32::from_rgb(0x53, 0x33, 0xad);
pub const DANGER: Color32 = Color32::from_rgb(0xe0, 0x55, 0x61);
use egui::{
Color32, CornerRadius, Rect, Rgba, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
ThemePreference, ViewportCommand, Visuals,
};
use parking_lot::RwLock;
use pstr_core::appearance::{Accent, Appearance, Flavor};
/// Card geometry. The grid is built from these, so a change here moves
/// everything together.
@@ -24,39 +36,259 @@ pub const CARD_WIDTH: f32 = 232.0;
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
pub const CARD_GAP: f32 = 14.0;
pub fn apply(ctx: &egui::Context) {
let mut visuals = Visuals::dark();
/// Every colour the app draws with, resolved from a [`Flavor`] and an
/// [`Accent`].
///
/// `Copy`, and small, so reading it is a lock and a memcpy rather than anything
/// a drawing loop has to think about.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
/// Behind everything: the page.
pub background: Color32,
/// The bars above and below it.
pub surface: Color32,
/// The deepest colour in the flavour — text fields, wells.
pub sunken: Color32,
/// Tiles, rows, forms.
pub card: Color32,
pub card_hover: Color32,
pub border: Color32,
pub text: Color32,
pub muted: Color32,
/// The one strong colour.
pub accent: Color32,
/// What a gradient in the accent runs into. Equal to [`Self::accent`] when
/// the accent is a single hue and gradients would have nothing to do.
pub accent_alt: Color32,
/// The accent taken down into the background, for a pressed control and for
/// selection behind text.
pub accent_dim: Color32,
/// Ink that stays readable on top of the accent. Chosen by the accent's
/// luminance, because Catppuccin's dark flavours have *pale* accents and
/// white-on-pastel is not readable at 13 px.
pub on_accent: Color32,
pub danger: Color32,
/// Whether this is a light theme.
pub light: bool,
}
visuals.panel_fill = BACKGROUND;
visuals.window_fill = SURFACE;
visuals.extreme_bg_color = Color32::from_rgb(0x0a, 0x0a, 0x0d);
visuals.faint_bg_color = SURFACE;
visuals.override_text_color = Some(TEXT);
visuals.selection.bg_fill = ACCENT_DIM;
visuals.selection.stroke = Stroke::new(1.0, TEXT);
visuals.hyperlink_color = ACCENT;
/// Which way a gradient runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Horizontal,
Vertical,
}
/// The palette as it stands.
pub fn palette() -> Palette {
ACTIVE.read().palette
}
pub fn background() -> Color32 {
palette().background
}
pub fn surface() -> Color32 {
palette().surface
}
pub fn card() -> Color32 {
palette().card
}
pub fn card_hover() -> Color32 {
palette().card_hover
}
pub fn text() -> Color32 {
palette().text
}
pub fn muted() -> Color32 {
palette().muted
}
pub fn accent() -> Color32 {
palette().accent
}
pub fn accent_dim() -> Color32 {
palette().accent_dim
}
pub fn on_accent() -> Color32 {
palette().on_accent
}
pub fn danger() -> Color32 {
palette().danger
}
/// Whether the accent is currently drawn as a gradient.
///
/// True only when the viewer has gradients on *and* the accent is a pair of
/// hues rather than one: a "gradient" from a colour to itself is a flat fill
/// that costs a texture lookup.
pub fn gradients() -> bool {
let active = ACTIVE.read();
active.gradients && active.palette.accent != active.palette.accent_alt
}
/// Fill `rect` with the accent, as a gradient when there is one to draw.
///
/// `lift` brightens the result for a hovered control and darkens it for a
/// pressed one — `0.0` is the resting state. It is a veil over the top rather
/// than a change to the colours, because the gradient is a texture and tinting
/// a texture can only ever darken it.
pub fn accent_fill(
painter: &egui::Painter,
rect: Rect,
corner_radius: impl Into<CornerRadius>,
lift: f32,
) {
let corner_radius = corner_radius.into();
fill(painter, rect, corner_radius, &palette());
veil(painter, rect, corner_radius, lift);
}
/// The same, for a palette that is not the one on screen: the accent swatches
/// on the settings page, each of which previews the theme clicking it would
/// give.
pub fn swatch_fill(
painter: &egui::Painter,
rect: Rect,
corner_radius: impl Into<CornerRadius>,
palette: &Palette,
) {
fill(painter, rect, corner_radius.into(), palette);
}
/// Paint `palette`'s accent — gradient if it has two hues and gradients are on,
/// flat otherwise.
fn fill(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, palette: &Palette) {
if !ACTIVE.read().gradients || palette.accent == palette.accent_alt {
painter.rect_filled(rect, corner_radius, palette.accent);
return;
}
let texture = ramp(
painter.ctx(),
palette.accent,
palette.accent_alt,
Direction::Horizontal,
);
painter.add(textured(rect, corner_radius, &texture));
}
/// The one gradient that is not the accent: the top bar, which runs from the
/// surface colour down into the page so that the join between them is a fade
/// rather than an edge.
///
/// Handed back as a shape rather than painted, because a panel's background has
/// to be drawn *behind* content whose size is only known once it is laid out —
/// the caller reserves an index before drawing and fills it in after.
pub fn bar_shape(ctx: &egui::Context, rect: Rect) -> egui::Shape {
let palette = palette();
if !ACTIVE.read().gradients {
return egui::epaint::RectShape::filled(rect, CornerRadius::ZERO, palette.surface).into();
}
let texture = ramp(
ctx,
palette.surface,
// Not all the way to the page colour: a bar that ends in exactly the
// background has no bottom edge, and the shadow under a top panel then
// reads as the only thing separating the chrome from the content.
mix(palette.surface, palette.background, 0.7),
Direction::Vertical,
);
textured(rect, CornerRadius::ZERO, &texture)
}
/// A white or black film over `rect`, for hover and press states. A no-op at
/// zero, which is the case that matters — most controls are at rest.
fn veil(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, lift: f32) {
if lift == 0.0 {
return;
}
let alpha = (lift.abs() * 255.0).clamp(0.0, 255.0) as u8;
let film = if lift > 0.0 {
Color32::from_white_alpha(alpha)
} else {
Color32::from_black_alpha(alpha)
};
painter.rect_filled(rect, corner_radius, film);
}
/// A rounded rectangle painted with a gradient texture.
///
/// The fill is white because `Brush` *multiplies* the texture by it, so
/// anything else would tint the ramp.
fn textured(rect: Rect, corner_radius: CornerRadius, texture: &TextureHandle) -> egui::Shape {
let mut shape = egui::epaint::RectShape::filled(rect, corner_radius, Color32::WHITE);
shape.brush = Some(Arc::new(egui::epaint::Brush {
fill_texture_id: texture.id(),
uv: Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
}));
shape.into()
}
/// Install a palette, and tell egui and the window manager which of the two
/// kinds of theme it is.
pub fn apply(ctx: &egui::Context, appearance: Appearance) {
let palette = Palette::resolve(appearance);
// egui keeps a separate style per theme and follows the OS by default, and
// `set_visuals` only writes the one that is active *right now*. On the
// first call this runs before the platform has reported its theme, so on a
// Windows desktop set to light the palette below would land on the unused
// dark style and then be swapped out — panels stayed dark because they are
// painted by hand, but every stock widget (buttons, the search field, the
// volume slider) came back white. Pin the preference and write both styles.
let theme = if palette.light {
Theme::Light
} else {
Theme::Dark
};
ctx.set_theme(match theme {
Theme::Light => ThemePreference::Light,
Theme::Dark => ThemePreference::Dark,
});
// The native title bar follows the *window's* theme, not egui's. Without
// this, Windows draws a light caption above a dark window.
ctx.send_viewport_cmd(ViewportCommand::SetTheme(match theme {
Theme::Light => SystemTheme::Light,
Theme::Dark => SystemTheme::Dark,
}));
let mut visuals = if palette.light {
Visuals::light()
} else {
Visuals::dark()
};
visuals.panel_fill = palette.background;
visuals.window_fill = palette.surface;
visuals.extreme_bg_color = palette.sunken;
visuals.faint_bg_color = palette.surface;
visuals.override_text_color = Some(palette.text);
visuals.selection.bg_fill = palette.accent_dim;
visuals.selection.stroke = Stroke::new(1.0, palette.text);
visuals.hyperlink_color = palette.accent;
let radius = CornerRadius::same(8);
visuals.widgets.noninteractive.bg_fill = SURFACE;
visuals.widgets.noninteractive.bg_fill = palette.surface;
visuals.widgets.noninteractive.corner_radius = radius;
visuals.widgets.noninteractive.bg_stroke =
Stroke::new(1.0, Color32::from_rgb(0x26, 0x26, 0x30));
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border);
visuals.widgets.inactive.bg_fill = CARD;
visuals.widgets.inactive.weak_bg_fill = CARD;
visuals.widgets.inactive.bg_fill = palette.card;
visuals.widgets.inactive.weak_bg_fill = palette.card;
visuals.widgets.inactive.corner_radius = radius;
visuals.widgets.inactive.bg_stroke = Stroke::NONE;
visuals.widgets.hovered.bg_fill = CARD_HOVER;
visuals.widgets.hovered.weak_bg_fill = CARD_HOVER;
visuals.widgets.hovered.bg_fill = palette.card_hover;
visuals.widgets.hovered.weak_bg_fill = palette.card_hover;
visuals.widgets.hovered.corner_radius = radius;
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, ACCENT);
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, palette.accent);
visuals.widgets.active.bg_fill = ACCENT_DIM;
visuals.widgets.active.weak_bg_fill = ACCENT_DIM;
visuals.widgets.active.bg_fill = palette.accent_dim;
visuals.widgets.active.weak_bg_fill = palette.accent_dim;
visuals.widgets.active.corner_radius = radius;
ctx.set_visuals(visuals);
// Both, not just the active one: a menu or a tooltip that opens while the
// platform is mid-way through telling us its theme should not flash the
// stock palette.
ctx.set_visuals_of(Theme::Dark, visuals.clone());
ctx.set_visuals_of(Theme::Light, visuals);
ctx.all_styles_mut(|style| {
style.spacing.item_spacing = egui::vec2(10.0, 10.0);
@@ -78,4 +310,441 @@ pub fn apply(ctx: &egui::Context) {
]
.into();
});
// Dropped, not rebuilt: the textures are only wanted if something draws a
// gradient this frame, and building them here would load two textures on
// every theme change including the ones nothing gradient-filled follows.
let mut active = ACTIVE.write();
active.palette = palette;
active.gradients = appearance.gradients;
active.ramps.clear();
drop(active);
ctx.request_repaint();
}
/// The palette, and the gradient textures drawn from it.
struct Active {
palette: Palette,
gradients: bool,
/// Gradient ramps by the pair of colours and the direction they run,
/// emptied whenever the palette changes.
///
/// Keyed on the colours rather than on what they are *for* because the
/// settings page previews every accent at once: eight swatches in a
/// flavour the window is not wearing are eight ramps that belong to no
/// palette in particular. Bounded by the number of flavours times the
/// number of accents, and only the ones actually drawn.
ramps: BTreeMap<(u32, u32, bool), TextureHandle>,
}
static ACTIVE: RwLock<Active> = RwLock::new(Active {
palette: Palette::PROTON,
gradients: true,
ramps: BTreeMap::new(),
});
/// A ramp between two colours, built once and kept.
fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -> TextureHandle {
let key = (
u32::from_le_bytes(from.to_array()),
u32::from_le_bytes(to.to_array()),
direction == Direction::Vertical,
);
if let Some(texture) = ACTIVE.read().ramps.get(&key) {
return texture.clone();
}
let pixels: Vec<Color32> = (0..RAMP_STOPS)
.map(|stop| mix(from, to, stop as f32 / (RAMP_STOPS - 1) as f32))
.collect();
let size = match direction {
Direction::Horizontal => [RAMP_STOPS, 1],
Direction::Vertical => [1, RAMP_STOPS],
};
let texture = ctx.load_texture(
"pstr-ramp",
egui::ColorImage::new(size, pixels),
// Clamped and filtered: the ramp is stretched across a whole seek bar,
// and nearest-neighbour would show every one of its stops.
TextureOptions::LINEAR,
);
ACTIVE.write().ramps.insert(key, texture.clone());
texture
}
/// How many texels a ramp is drawn from.
///
/// Sampled linearly, so this is not the number of visible steps — it is how
/// closely the ramp follows sRGB. Interpolation happens in the texture's own
/// gamma space, and a two-texel ramp between distant hues takes a visibly
/// different path through colour space than one built stop by stop in linear
/// light, which is what this does.
const RAMP_STOPS: usize = 64;
/// Blend two colours in linear light. Mixing in sRGB darkens the middle of a
/// ramp between saturated hues, which is exactly where a gradient is looked at.
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
let from = Rgba::from(from);
let to = Rgba::from(to);
Color32::from(from * (1.0 - t) + to * t)
}
/// Relative luminance, in linear light, as WCAG defines it.
fn luminance(color: Color32) -> f32 {
let rgba = Rgba::from(color);
0.2126 * rgba.r() + 0.7152 * rgba.g() + 0.0722 * rgba.b()
}
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
/// 21.0 for black against white.
fn contrast(one: Color32, other: Color32) -> f32 {
let (one, other) = (luminance(one), luminance(other));
(one.max(other) + 0.05) / (one.min(other) + 0.05)
}
/// The least contrast a label gets anywhere along a gradient between two
/// colours. Sampled rather than solved: the worst point is not always an end,
/// because the ink can be brighter than one end and darker than the other.
fn worst_contrast(from: Color32, to: Color32, ink: Color32) -> f32 {
(0..=4)
.map(|step| contrast(mix(from, to, step as f32 / 4.0), ink))
.fold(f32::INFINITY, f32::min)
}
/// The least contrast the app will accept between a label and the fill under
/// it: WCAG AA for large text, which is what wears the accent — button labels,
/// a tab, a play glyph, never body copy.
const MIN_CONTRAST: f32 = 3.0;
/// One flavour's raw colours, named as Catppuccin names them.
///
/// Stored rather than computed: these are somebody else's palette, and the
/// point of using it is to use it exactly.
#[derive(Clone, Copy)]
struct Ramp {
base: Color32,
mantle: Color32,
crust: Color32,
surface0: Color32,
surface1: Color32,
subtext0: Color32,
text: Color32,
pink: Color32,
mauve: Color32,
sky: Color32,
sapphire: Color32,
blue: Color32,
lavender: Color32,
teal: Color32,
green: Color32,
peach: Color32,
yellow: Color32,
red: Color32,
light: bool,
}
const fn rgb(hex: u32) -> Color32 {
Color32::from_rgb(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
)
}
/// The palette this app shipped with, extended to the hues an accent can be.
///
/// Only `mauve` — Proton purple — and `red` are from the original; the rest are
/// chosen to sit on a near-black base at roughly the saturation that one does,
/// which is a good deal hotter than any Catppuccin flavour.
const PROTON: Ramp = Ramp {
base: rgb(0x0e0e12),
mantle: rgb(0x17171d),
crust: rgb(0x0a0a0d),
surface0: rgb(0x1e1e26),
surface1: rgb(0x2a2a35),
subtext0: rgb(0x8e8e9c),
text: rgb(0xeaeaf0),
pink: rgb(0xff5fbe),
mauve: rgb(0x7d4dff),
sky: rgb(0x4dd2ff),
sapphire: rgb(0x3a8fc4),
blue: rgb(0x3f6fe0),
lavender: rgb(0xa68cff),
teal: rgb(0x3fd6b8),
green: rgb(0x56d364),
peach: rgb(0xff9a4d),
yellow: rgb(0xffd166),
red: rgb(0xe05561),
light: false,
};
const LATTE: Ramp = Ramp {
base: rgb(0xeff1f5),
mantle: rgb(0xe6e9ef),
crust: rgb(0xdce0e8),
surface0: rgb(0xccd0da),
surface1: rgb(0xbcc0cc),
subtext0: rgb(0x6c6f85),
text: rgb(0x4c4f69),
pink: rgb(0xea76cb),
mauve: rgb(0x8839ef),
sky: rgb(0x04a5e5),
sapphire: rgb(0x209fb5),
blue: rgb(0x1e66f5),
lavender: rgb(0x7287fd),
teal: rgb(0x179299),
green: rgb(0x40a02b),
peach: rgb(0xfe640b),
yellow: rgb(0xdf8e1d),
red: rgb(0xd20f39),
light: true,
};
const FRAPPE: Ramp = Ramp {
base: rgb(0x303446),
mantle: rgb(0x292c3c),
crust: rgb(0x232634),
surface0: rgb(0x414559),
surface1: rgb(0x51576d),
subtext0: rgb(0xa5adce),
text: rgb(0xc6d0f5),
pink: rgb(0xf4b8e4),
mauve: rgb(0xca9ee6),
sky: rgb(0x99d1db),
sapphire: rgb(0x85c1dc),
blue: rgb(0x8caaee),
lavender: rgb(0xbabbf1),
teal: rgb(0x81c8be),
green: rgb(0xa6d189),
peach: rgb(0xef9f76),
yellow: rgb(0xe5c890),
red: rgb(0xe78284),
light: false,
};
const MACCHIATO: Ramp = Ramp {
base: rgb(0x24273a),
mantle: rgb(0x1e2030),
crust: rgb(0x181926),
surface0: rgb(0x363a4f),
surface1: rgb(0x494d64),
subtext0: rgb(0xa5adcb),
text: rgb(0xcad3f5),
pink: rgb(0xf5bde6),
mauve: rgb(0xc6a0f6),
sky: rgb(0x91d7e3),
sapphire: rgb(0x7dc4e4),
blue: rgb(0x8aadf4),
lavender: rgb(0xb7bdf8),
teal: rgb(0x8bd5ca),
green: rgb(0xa6da95),
peach: rgb(0xf5a97f),
yellow: rgb(0xeed49f),
red: rgb(0xed8796),
light: false,
};
const MOCHA: Ramp = Ramp {
base: rgb(0x1e1e2e),
mantle: rgb(0x181825),
crust: rgb(0x11111b),
surface0: rgb(0x313244),
surface1: rgb(0x45475a),
subtext0: rgb(0xa6adc8),
text: rgb(0xcdd6f4),
pink: rgb(0xf5c2e7),
mauve: rgb(0xcba6f7),
sky: rgb(0x89dceb),
sapphire: rgb(0x74c7ec),
blue: rgb(0x89b4fa),
lavender: rgb(0xb4befe),
teal: rgb(0x94e2d5),
green: rgb(0xa6e3a1),
peach: rgb(0xfab387),
yellow: rgb(0xf9e2af),
red: rgb(0xf38ba8),
light: false,
};
impl Ramp {
const fn of(flavor: Flavor) -> Self {
match flavor {
Flavor::Proton => PROTON,
Flavor::Latte => LATTE,
Flavor::Frappe => FRAPPE,
Flavor::Macchiato => MACCHIATO,
Flavor::Mocha => MOCHA,
}
}
/// The accent, and the hue a gradient in it runs into.
///
/// The partners are neighbours on the wheel rather than complements: a
/// gradient across half the spectrum passes through a colour that belongs
/// to neither end, and on a seek bar that reads as a bug. The exception is
/// [`Accent::PinkSky`], which is the whole point of that entry.
const fn accents(&self, accent: Accent) -> (Color32, Color32) {
match accent {
Accent::Mauve => (self.mauve, self.blue),
Accent::Pink => (self.pink, self.mauve),
Accent::Sky => (self.sky, self.sapphire),
Accent::PinkSky => (self.pink, self.sky),
Accent::Lavender => (self.lavender, self.blue),
Accent::Blue => (self.blue, self.sapphire),
Accent::Teal => (self.teal, self.green),
Accent::Peach => (self.peach, self.yellow),
}
}
}
impl Palette {
/// The default, spelled out as a constant so the global has something to
/// hold before [`apply`] first runs — a `Ui` built during startup, or a
/// panic message painted before the engine exists, still gets colours.
const PROTON: Self = Self {
background: PROTON.base,
surface: PROTON.mantle,
sunken: PROTON.crust,
card: PROTON.surface0,
card_hover: PROTON.surface1,
border: rgb(0x262630),
text: PROTON.text,
muted: PROTON.subtext0,
accent: PROTON.mauve,
accent_alt: PROTON.blue,
accent_dim: rgb(0x5333ad),
on_accent: PROTON.text,
danger: PROTON.red,
light: false,
};
/// Resolve a choice into colours.
pub fn resolve(appearance: Appearance) -> Self {
let ramp = Ramp::of(appearance.flavor);
let (accent, partner) = ramp.accents(appearance.accent);
// Catppuccin's Latte accents are tuned to be *read*, on a light page,
// at body-text weight — which makes them mid-luminance, and a
// mid-luminance fill is one that neither black nor white sits on. They
// are taken down in value here, and only here: the hue and the
// saturation are Latte's, so it still looks like Latte, and a label on
// a button is legible.
let deepen = |color: Color32| {
if ramp.light {
mix(color, Color32::BLACK, 0.30)
} else {
color
}
};
let accent = deepen(accent);
let accent_alt = if appearance.gradients {
deepen(partner)
} else {
accent
};
// Ink that survives on the accent. The flavour's own reading first —
// pale text on a dark theme — and the opposite when that does not clear
// the bar, which on Catppuccin's dark flavours is most of the time:
// their accents are *pastel*, and white on #f5c2e7 is not a label.
//
// Latte's darkest colour is its body text, and even that is only #4c4f69,
// so the dark side is taken a third of the way to black.
let ink_dark = if ramp.light {
mix(ramp.text, Color32::BLACK, 0.35)
} else {
ramp.crust
};
let ink_light = if ramp.light { ramp.base } else { ramp.text };
let on_accent = if worst_contrast(accent, accent_alt, ink_light) >= MIN_CONTRAST {
ink_light
} else {
ink_dark
};
Self {
background: ramp.base,
surface: ramp.mantle,
sunken: ramp.crust,
card: ramp.surface0,
card_hover: ramp.surface1,
border: ramp.surface1,
text: ramp.text,
muted: ramp.subtext0,
accent,
accent_alt,
// Behind text, so it is the accent taken most of the way back to
// the page: a selection in a saturated hue is a selection nobody
// can read through.
accent_dim: mix(accent, ramp.base, if ramp.light { 0.55 } else { 0.45 }),
on_accent,
danger: ramp.red,
light: ramp.light,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_flavour_and_accent_resolves_to_readable_ink() {
for flavor in Flavor::ALL {
for accent in Accent::ALL {
let palette = Palette::resolve(Appearance {
flavor,
accent,
gradients: true,
});
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
assert!(
ratio >= MIN_CONTRAST,
"{flavor:?}/{accent:?}: contrast {ratio:.2}",
);
}
}
}
#[test]
fn only_the_light_flavour_reports_itself_light() {
for flavor in Flavor::ALL {
let palette = Palette::resolve(Appearance {
flavor,
..Appearance::default()
});
assert_eq!(palette.light, flavor.is_light());
// And the page is on the right side of the middle either way.
assert_eq!(luminance(palette.background) > 0.5, flavor.is_light());
}
}
#[test]
fn turning_gradients_off_leaves_one_colour_to_draw() {
let flat = Palette::resolve(Appearance {
accent: Accent::PinkSky,
gradients: false,
..Appearance::default()
});
assert_eq!(flat.accent, flat.accent_alt);
}
#[test]
fn the_shipped_default_is_the_palette_that_was_here_before() {
let resolved = Palette::resolve(Appearance::default());
assert_eq!(resolved.background, Palette::PROTON.background);
assert_eq!(resolved.accent, Palette::PROTON.accent);
assert_eq!(resolved.text, Palette::PROTON.text);
assert_eq!(resolved.muted, Palette::PROTON.muted);
}
#[test]
fn a_ramp_runs_from_one_colour_to_the_other() {
let from = Color32::from_rgb(0, 0, 0);
let to = Color32::from_rgb(255, 255, 255);
assert_eq!(mix(from, to, 0.0), from);
assert_eq!(mix(from, to, 1.0), to);
assert!(luminance(mix(from, to, 0.5)) > luminance(from));
assert!(luminance(mix(from, to, 0.5)) < luminance(to));
}
}
+40 -6
View File
@@ -118,7 +118,9 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
name: &title.name,
subtitle: subtitle(title),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: (title.kind == TitleKind::Film).then(|| "Film".to_string()),
// What a film is says itself in the subtitle below the
// card; a second `Film` over the poster is noise.
badge: None,
},
)
.clicked();
@@ -132,13 +134,19 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
}
}
/// The grey line under a card: how much there is, and how much is left.
/// 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 title
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "Film".into());
return match title.year {
Some(year) => format!("Film · {year}"),
None => "Film".into(),
};
}
let total = title.episode_count();
let watched = title.watched_count();
@@ -182,6 +190,32 @@ pub fn plural(count: usize, noun: &str) -> String {
mod tests {
use super::*;
fn title(kind: TitleKind, year: Option<u32>) -> 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 plurals_read_naturally() {
assert_eq!(plural(1, "title"), "1 title");
+286
View File
@@ -0,0 +1,286 @@
//! Picking a title's provider entry by hand.
//!
//! The matcher is deliberately unwilling to guess — see
//! [`pstr_meta::matching::MATCH_FLOOR`], and the reasoning that a wrong poster
//! does not look like a bug but like the library being wrong. The cost of that
//! is a handful of titles it will not decide about, and this dialog is where
//! they get decided: the provider's own answers, unscored and in its own order,
//! and a click that pins one of them.
//!
//! Nothing here scores anything. A person reading a list of eight entries with
//! their posters next to them is not guessing, which is exactly why the floor
//! that stops the scorer should not also stop them.
use pstr_core::metadata::{ProviderId, TitleMetadata};
use crate::app::{Action, Matcher};
use crate::theme;
use crate::ui::{self, Art};
/// How tall the result list may get before it scrolls. Enough for four rows,
/// which is what a page of provider answers usually needs.
const RESULTS_HEIGHT: f32 = 340.0;
/// A result row's poster, at the shape a cover image actually is.
const THUMB: egui::Vec2 = egui::vec2(46.0, 69.0);
pub fn show(
ctx: &egui::Context,
matcher: &mut Matcher,
art: &mut Art<'_>,
provider: ProviderId,
actions: &mut Vec<Action>,
) {
// What the title is matched to now, cloned out before `art` is borrowed for
// the rows below.
let current = art
.metadata
.get(&matcher.title_key)
.map(|record| (record.metadata.clone(), record.manual));
let modal = egui::Modal::new(egui::Id::new("matcher"))
.frame(
egui::Frame::new()
.fill(theme::surface())
.inner_margin(egui::Margin::same(18))
.corner_radius(egui::CornerRadius::same(10)),
)
.show(ctx, |ui| {
ui.set_width(620.0);
ui.label(
egui::RichText::new(format!("Match “{}”", matcher.title_name))
.size(18.0)
.strong()
.color(theme::text()),
);
ui.label(ui::muted(format!(
"Everything {} answers with, in its own order — nothing is scored here.",
provider.label()
)));
ui.add_space(10.0);
search_row(ui, matcher, actions);
ui.add_space(6.0);
current_row(ui, matcher, current.as_ref(), actions);
ui.add_space(10.0);
if let Some(error) = &matcher.error {
ui.label(egui::RichText::new(error).size(12.0).color(theme::danger()));
ui.add_space(6.0);
}
results(ui, matcher, art, actions);
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
actions.push(Action::CloseMatcher);
}
});
});
});
// Escape, a click on the backdrop, or the close button above: all of them
// mean the same thing, and the dialog changes nothing until a row is
// clicked, so leaving it needs no confirmation.
if modal.should_close() {
actions.push(Action::CloseMatcher);
}
}
/// The box and the button. Enter searches, because a search box that ignores it
/// is a search box people think is broken.
fn search_row(ui: &mut egui::Ui, matcher: &mut Matcher, actions: &mut Vec<Action>) {
ui.horizontal(|ui| {
let box_width = ui.available_width() - 90.0;
let field = ui.add(
egui::TextEdit::singleline(&mut matcher.query)
.hint_text("Title to search for")
.desired_width(box_width.max(120.0)),
);
if std::mem::take(&mut matcher.focus) {
field.request_focus();
}
let entered = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
if matcher.searching {
ui.add(egui::Spinner::new().size(16.0));
} else if (ui.button("Search").clicked() || entered) && !matcher.query.trim().is_empty() {
actions.push(Action::SearchMatches);
}
});
}
/// What the title is matched to now, and the way back to no match at all.
fn current_row(
ui: &mut egui::Ui,
matcher: &Matcher,
current: Option<&(Option<TitleMetadata>, bool)>,
actions: &mut Vec<Action>,
) {
ui.horizontal(|ui| {
match current {
Some((Some(found), manual)) => {
ui.label(ui::muted(format!(
"Currently {}{}",
found.name,
if *manual { ", chosen by hand" } else { "" }
)));
// Only worth offering when there is something to undo: with no
// row stored, "clear" would delete nothing.
if ui
.button("Clear")
.on_hover_text("Forget this match and let the next match run decide again")
.clicked()
{
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
}
}
Some((None, _)) => {
ui.label(ui::muted("Currently unmatched — the provider had nothing."));
if ui
.button("Clear")
.on_hover_text("Forget the stored miss so this title is asked about again")
.clicked()
{
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
}
}
None => {
ui.label(ui::muted("Not matched yet."));
}
};
});
}
/// The provider's answers, one clickable row each.
fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mut Vec<Action>) {
if matcher.results.is_empty() {
ui.label(ui::muted(if matcher.searching {
"Searching…"
} else if matcher.asked {
// Both providers match on whole words, so a name the index has never
// seen empties the page rather than returning something near it.
"Nothing came back. Try a shorter name, or the title in another language."
} else {
"Search to see what the provider has."
}));
return;
}
egui::ScrollArea::vertical()
.max_height(RESULTS_HEIGHT)
.auto_shrink([false, true])
.show(ui, |ui| {
for found in &matcher.results {
if row(ui, art, found).clicked() {
actions.push(Action::ChooseMatch(Box::new(found.clone())));
}
ui.add_space(4.0);
}
});
}
/// One answer: its cover, its names, and enough of its synopsis to tell two
/// entries in the same franchise apart — which is the whole job of this list.
fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response {
let response = egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::same(8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
let (rect, _) = ui.allocate_exact_size(THUMB, egui::Sense::hover());
if let Some(texture) = poster(art, found) {
ui.painter().image(
texture.id(),
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
} else {
ui.painter().rect_filled(
rect,
egui::CornerRadius::same(4),
theme::card_hover(),
);
}
ui.add_space(10.0);
ui.vertical(|ui| {
ui.label(
egui::RichText::new(&found.name)
.size(14.0)
.strong()
.color(theme::text()),
);
if let Some(original) = &found.original_name {
ui.label(ui::muted(original.clone()));
}
ui.label(ui::muted(facts(found)));
if let Some(overview) = &found.overview {
let mut job = egui::text::LayoutJob::simple(
overview.replace('\n', " "),
egui::FontId::proportional(11.5),
theme::muted(),
ui.available_width(),
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
ui.label(job);
}
});
});
})
.response;
let response = response.interact(egui::Sense::click());
if response.hovered() {
ui.painter().rect_stroke(
response.rect,
egui::CornerRadius::same(6),
egui::Stroke::new(1.5, theme::accent()),
egui::StrokeKind::Inside,
);
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// The cover for one answer, cached under the provider's id for it rather than
/// under a title key — these are not any title's artwork until one is picked.
fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandle> {
let url = found
.poster_url
.clone()
.or_else(|| found.backdrop_url.clone())?;
let key = format!("option:{}:{}", found.provider.as_str(), found.remote_id);
let engine = art.engine;
let requested = key.clone();
art.posters
.texture(key, || engine.request_poster(requested, url))
}
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
/// simply absent.
fn facts(found: &TitleMetadata) -> String {
let mut parts: Vec<String> = vec![
match found.kind {
pstr_core::library::TitleKind::Series => "Series",
pstr_core::library::TitleKind::Film => "Film",
}
.to_string(),
];
if let Some(year) = found.year {
parts.push(year.to_string());
}
if let Some(episodes) = found.episodes {
parts.push(ui::library::plural(episodes as usize, "episode"));
}
if let Some(rating) = found.rating {
parts.push(format!("★ {rating:.1}"));
}
parts.join(" · ")
}
+71 -15
View File
@@ -4,6 +4,7 @@
//! applies them after the frame is drawn.
pub mod library;
pub mod matcher;
pub mod player;
pub mod shares;
pub mod title;
@@ -115,23 +116,74 @@ pub fn section(ui: &mut egui::Ui, text: &str) {
egui::RichText::new(text)
.size(17.0)
.strong()
.color(theme::TEXT),
.color(theme::text()),
);
ui.add_space(2.0);
}
/// Small grey text, for everything that is context rather than content.
pub fn muted(text: impl Into<String>) -> egui::RichText {
egui::RichText::new(text).size(12.0).color(theme::MUTED)
egui::RichText::new(text).size(12.0).color(theme::muted())
}
/// The one button style that means "this is the action".
pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
ui.add(
egui::Button::new(egui::RichText::new(text).strong().color(Color32::WHITE))
.fill(theme::ACCENT)
.corner_radius(CornerRadius::same(8)),
)
filled(ui, text, true, Vec2::new(0.0, 0.0))
}
/// A tab in the top bar. The selected one wears the accent; the others are text
/// until the pointer is over them.
pub fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response {
filled(ui, text, selected, Vec2::new(4.0, 0.0))
}
/// A button painted by hand, so that "filled with the accent" can mean a
/// gradient.
///
/// `egui::Button` takes a single `Color32` and there is no way in to give it
/// anything else, so the fill, the label and the hover states are all drawn
/// here. Everything else — sizing, padding, the click — is still egui's.
fn filled(ui: &mut egui::Ui, text: &str, accented: bool, extra: Vec2) -> egui::Response {
let padding = ui.spacing().button_padding + extra;
let galley = ui.painter().layout_no_wrap(
text.to_owned(),
egui::TextStyle::Button.resolve(ui.style()),
Color32::PLACEHOLDER,
);
let size = Vec2::new(
galley.size().x + padding.x * 2.0,
(galley.size().y + padding.y * 2.0).max(ui.spacing().interact_size.y),
);
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
if !ui.is_rect_visible(rect) {
return response;
}
let radius = CornerRadius::same(8);
let lift = if response.is_pointer_button_down_on() {
-0.14
} else if response.hovered() {
0.10
} else {
0.0
};
let ink = if accented {
theme::accent_fill(ui.painter(), rect, radius, lift);
theme::on_accent()
} else {
if response.hovered() {
ui.painter().rect_filled(rect, radius, theme::card_hover());
theme::text()
} else {
theme::muted()
}
};
let position = rect.center() - galley.size() / 2.0;
ui.painter().galley(position, galley, ink);
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// What a tile shows.
@@ -142,7 +194,7 @@ pub struct Card<'a> {
pub subtitle: String,
/// Fraction watched, drawn as a bar across the bottom of the still.
pub progress: Option<f32>,
/// A short label in the corner — `S01E04`, `Film`.
/// A short label in the corner — `S01E04`.
pub badge: Option<String>,
}
@@ -173,7 +225,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let hovered = response.hovered();
let painter = ui.painter();
painter.rect_filled(image_rect, radius, theme::CARD);
painter.rect_filled(image_rect, radius, theme::card());
match &card.art {
Some((texture, ArtShape::Landscape)) => {
@@ -199,7 +251,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
Align2::CENTER_CENTER,
initials(card.name),
egui::FontId::proportional(28.0),
theme::MUTED,
theme::muted(),
);
}
}
@@ -231,14 +283,14 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
painter.rect_filled(track, CornerRadius::ZERO, Color32::from_black_alpha(150));
let mut played = track;
played.set_width(track.width() * progress.clamp(0.0, 1.0));
painter.rect_filled(played, CornerRadius::ZERO, theme::ACCENT);
theme::accent_fill(painter, played, CornerRadius::ZERO, 0.0);
}
if hovered {
painter.rect_stroke(
image_rect,
radius,
Stroke::new(2.0, theme::ACCENT),
Stroke::new(2.0, theme::accent()),
egui::StrokeKind::Inside,
);
}
@@ -254,20 +306,24 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let mut job = egui::text::LayoutJob::simple(
card.name.to_string(),
egui::FontId::proportional(14.0),
if hovered { Color32::WHITE } else { theme::TEXT },
if hovered {
Color32::WHITE
} else {
theme::text()
},
width,
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
let name = painter.layout_job(job);
let name_height = name.size().y;
painter.galley(text_rect.min, name, theme::TEXT);
painter.galley(text_rect.min, name, theme::text());
painter.text(
text_rect.min + Vec2::new(0.0, name_height + 2.0),
Align2::LEFT_TOP,
card.subtitle,
egui::FontId::proportional(11.5),
theme::MUTED,
theme::muted(),
);
response.on_hover_cursor(egui::CursorIcon::PointingHand)
+5 -5
View File
@@ -288,7 +288,7 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
Align2::CENTER_CENTER,
&playback.target.title_name,
egui::FontId::proportional(20.0),
theme::TEXT,
theme::text(),
);
painter.text(
rect.center() + Vec2::new(0.0, 10.0),
@@ -299,7 +299,7 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
"playing in mpv's own window"
},
egui::FontId::proportional(13.0),
theme::MUTED,
theme::muted(),
);
}
@@ -314,14 +314,14 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
Align2::CENTER_CENTER,
"Up next",
egui::FontId::proportional(14.0),
theme::MUTED,
theme::muted(),
);
ui.painter().text(
rect.center() + Vec2::new(0.0, 12.0),
Align2::CENTER_CENTER,
opening.unwrap_or("opening…"),
egui::FontId::proportional(20.0),
theme::TEXT,
theme::text(),
);
ui.scope_builder(
@@ -451,7 +451,7 @@ fn up_next_card(
ui.label(
egui::RichText::new(format!("in {}s", card.seconds.ceil() as u32))
.size(12.0)
.color(theme::ACCENT),
.color(theme::accent()),
);
});
});
+113 -7
View File
@@ -7,18 +7,30 @@
//! typed.
use pstr_core::Share;
use pstr_core::appearance::{Accent, Appearance, Flavor};
use pstr_core::metadata::{MetadataConfig, ProviderId};
use crate::app::{Action, ShareForm};
use crate::theme;
use crate::ui;
/// The settings on this page that are a single value rather than a form.
///
/// Bundled for the same reason [`crate::ui::Art`] is: they arrive together,
/// they are read-only, and passing them one by one is what pushed this
/// function past the argument count anybody can read.
#[derive(Debug, Clone, Copy)]
pub struct Prefs {
pub autoplay: bool,
pub appearance: Appearance,
}
pub fn show(
ui: &mut egui::Ui,
shares: &[Share],
form: &mut ShareForm,
settings: &mut MetadataConfig,
autoplay: bool,
prefs: Prefs,
api_key: &mut String,
actions: &mut Vec<Action>,
) {
@@ -37,13 +49,107 @@ pub fn show(
add_form(ui, form, actions);
ui.add_space(28.0);
playback_form(ui, autoplay, actions);
playback_form(ui, prefs.autoplay, actions);
ui.add_space(28.0);
appearance_form(ui, prefs.appearance, actions);
ui.add_space(28.0);
metadata_form(ui, settings, api_key, actions);
});
}
/// The theme: a flavour, an accent, and whether the accent is a gradient.
///
/// Every control here repaints the whole window the moment it is clicked —
/// there is no "apply" — because the preview *is* the app. A swatch row that
/// only changed a small square would be asking someone to imagine the result.
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
ui::section(ui, "Appearance");
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted("Palette"));
for flavor in Flavor::ALL {
if ui
.radio(appearance.flavor == flavor, flavor.label())
.on_hover_text(flavor.description())
.clicked()
&& appearance.flavor != flavor
{
actions.push(Action::SetAppearance(Appearance {
flavor,
..appearance
}));
}
}
ui.add_space(12.0);
ui.label(ui::muted("Accent"));
ui.horizontal_wrapped(|ui| {
for accent in Accent::ALL {
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
actions.push(Action::SetAppearance(Appearance {
accent,
..appearance
}));
}
}
});
ui.add_space(12.0);
let mut gradients = appearance.gradients;
if ui
.checkbox(&mut gradients, "Fade the accent into a second colour")
.on_hover_text(
"Off draws it flat — better on a panel that bands a gradient into stripes",
)
.changed()
{
actions.push(Action::SetAppearance(Appearance {
gradients,
..appearance
}));
}
});
}
/// One accent, drawn in the colours it would actually produce.
///
/// Resolved against the *selected* flavour rather than the active palette, so
/// the row previews what clicking it would give rather than what is on screen.
fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
let selected = appearance.accent == accent;
let size = egui::Vec2::new(52.0, 26.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if ui.is_rect_visible(rect) {
let palette = theme::Palette::resolve(Appearance {
accent,
..appearance
});
let radius = egui::CornerRadius::same(6);
theme::swatch_fill(ui.painter(), rect, radius, &palette);
if selected {
// A ring rather than a tick: the tick would have to be drawn in an
// ink that reads on eight different fills.
ui.painter().rect_stroke(
rect.expand(2.0),
egui::CornerRadius::same(8),
egui::Stroke::new(2.0, theme::text()),
egui::StrokeKind::Outside,
);
}
}
response
.on_hover_text(accent.label())
.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// Enrichment: the switch, the provider, and the cost of turning it on.
///
/// The privacy note is not decoration and it is not in a tooltip. Turning this
@@ -59,7 +165,7 @@ fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) {
ui::section(ui, "Playback");
egui::Frame::new()
.fill(theme::CARD)
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
@@ -89,7 +195,7 @@ fn metadata_form(
ui::section(ui, "Posters and descriptions");
egui::Frame::new()
.fill(theme::CARD)
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
@@ -180,7 +286,7 @@ fn metadata_form(
fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
egui::Frame::new()
.fill(theme::CARD)
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
@@ -198,7 +304,7 @@ fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new("Remove").fill(theme::CARD_HOVER))
.add(egui::Button::new("Remove").fill(theme::card_hover()))
.on_hover_text("Forget this share, its catalog rows and its stored secrets")
.clicked()
{
@@ -222,7 +328,7 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
ui.add_space(10.0);
egui::Frame::new()
.fill(theme::CARD)
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
+37 -14
View File
@@ -61,10 +61,9 @@ pub fn show(
fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
// Cloned out of the borrow: `art` is held mutably for the card below, and
// the description is drawn beside it.
let found = art
.metadata
.get(&title.key)
.and_then(|record| record.metadata.clone());
let record = art.metadata.get(&title.key);
let found = record.and_then(|record| record.metadata.clone());
let by_hand = record.is_some_and(|record| record.manual);
ui.horizontal_top(|ui| {
ui::card(
@@ -87,7 +86,11 @@ fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec
if let Some(found) = &found
&& found.name != title.name
{
ui.label(ui::muted(format!("also known as {}", found.name)));
ui.label(ui::muted(format!(
"also known as {}{}",
found.name,
if by_hand { " (chosen by hand)" } else { "" }
)));
}
ui.add_space(4.0);
ui.label(ui::muted(meta_line(title, found.as_ref())));
@@ -122,6 +125,21 @@ fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec
}
}
}
// The way out of a match the scorer would not make, or made
// wrongly. Offered whether or not anything matched: the two
// cases it exists for are a title with no poster at all and a
// title wearing someone else's.
if ui
.button(match &found {
Some(_) => "Change match",
None => "Match…",
})
.on_hover_text("Search the metadata provider and pick the entry yourself")
.clicked()
{
actions.push(Action::OpenMatcher(title.key.clone()));
}
});
if let Some(found) = &found {
@@ -140,7 +158,12 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
}
if let Some(overview) = &found.overview {
ui.add(
egui::Label::new(egui::RichText::new(overview).size(13.0).color(theme::TEXT)).wrap(),
egui::Label::new(
egui::RichText::new(overview)
.size(13.0)
.color(theme::text()),
)
.wrap(),
);
}
if let Some(url) = &found.url {
@@ -215,9 +238,9 @@ fn episode_row(
let watched = episode.is_watched();
egui::Frame::new()
.fill(if watched {
theme::BACKGROUND
theme::background()
} else {
theme::CARD
theme::card()
})
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::symmetric(10, 8))
@@ -230,7 +253,7 @@ fn episode_row(
if ui
.add(
egui::Button::new(egui::RichText::new("▶").size(13.0))
.fill(theme::CARD_HOVER),
.fill(theme::card_hover()),
)
.on_hover_text("Play")
.clicked()
@@ -247,7 +270,7 @@ fn episode_row(
egui::Label::new(
egui::RichText::new(numbering)
.monospace()
.color(theme::MUTED),
.color(theme::muted()),
),
);
@@ -259,9 +282,9 @@ fn episode_row(
.and_then(|(name, _, _)| name.clone())
.unwrap_or_else(|| episode.detail().to_string());
let label = egui::RichText::new(name).color(if watched {
theme::MUTED
theme::muted()
} else {
theme::TEXT
theme::text()
});
let hover = match &found {
// The synopsis, where the provider has one — AniList does
@@ -306,7 +329,7 @@ fn episode_row(
ui.label(
egui::RichText::new("empty")
.size(12.0)
.color(theme::DANGER),
.color(theme::danger()),
)
.on_hover_text(
"The share reports no content for this file; it cannot be played.",
@@ -321,7 +344,7 @@ fn episode_row(
ui.label(
egui::RichText::new(format!("resume {}", ui::format_time(at)))
.size(12.0)
.color(theme::ACCENT),
.color(theme::accent()),
);
}
});
+82 -12
View File
@@ -10,7 +10,7 @@
//! Both push the same [`Action`]s, so there is one behaviour and two layouts,
//! and neither mutates: this module draws.
use egui::{Color32, CornerRadius, Rect, Sense, Vec2};
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Vec2, pos2};
use pstr_player::{MAX_VOLUME, Track, TrackKind};
use crate::app::{Action, Adjacent, Page};
@@ -26,6 +26,20 @@ const SEEK_HIT_HEIGHT: f32 = 18.0;
/// enough that it does not read as a second seek bar.
const VOLUME_WIDTH: f32 = 90.0;
/// Height of the transport row, and the reason it is a constant.
///
/// `Align::Center` in a horizontal layout centres each widget against the row
/// height *known when that widget is added*, and the row only grows as it goes.
/// The play button is the tallest thing in the cluster and sits in the middle,
/// so everything left of it — previous, −10s — is centred against a shorter row
/// and lands a few pixels high. Calling `set_min_height` with the tallest
/// widget's height before adding anything gives every one of them the same
/// height to centre against.
const ROW_HEIGHT_FULL: f32 = 44.0;
/// The same, for the library strip. See [`ROW_HEIGHT_FULL`].
const ROW_HEIGHT_MINI: f32 = 34.0;
/// Whether there is a file before and after this one in the same title.
#[derive(Debug, Clone, Copy, Default)]
pub struct Neighbours {
@@ -45,6 +59,8 @@ pub fn full(
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.set_min_height(ROW_HEIGHT_FULL);
// Left cluster: everything that moves the playhead, in the order a
// hand reaches for it.
step_button(
@@ -93,13 +109,15 @@ pub fn mini(
actions: &mut Vec<Action>,
) {
ui.horizontal(|ui| {
ui.set_min_height(ROW_HEIGHT_MINI);
// The way back to the picture. First, and a real button rather than a
// label, because leaving the player page with Esc is otherwise a
// one-way trip: playback carries on with nowhere to watch it.
if ui
.add(
egui::Button::new(egui::RichText::new("Back to video").size(13.0))
.fill(theme::CARD_HOVER)
.fill(theme::card_hover())
.corner_radius(CornerRadius::same(8)),
)
.on_hover_text("Show the picture again")
@@ -167,15 +185,63 @@ pub fn mini(
}
/// The one button whose position should never move.
///
/// Painted rather than set as `▶`/`⏸` text. A glyph is centred by its advance
/// width, and `U+25B6`'s advance carries side bearings that are not symmetric —
/// in a round button that reads, correctly, as a triangle sitting off to one
/// side. It also comes from whichever fallback font has it, so the shape and its
/// weight vary by platform. Two polygons cost less than either problem.
fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, size: f32) {
let symbol = if playback.paused { "▶" } else { "⏸" };
if ui
.add(
egui::Button::new(egui::RichText::new(symbol).size(size * 0.42))
.fill(theme::ACCENT)
.corner_radius(CornerRadius::same((size / 2.0) as u8))
.min_size(Vec2::splat(size)),
)
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
let lift = if response.is_pointer_button_down_on() {
-0.18
} else if response.hovered() {
0.12
} else {
0.0
};
let painter = ui.painter();
// A rounded rectangle whose corner radius is its half-width *is* a circle,
// and unlike `circle_filled` it can be painted with a gradient.
theme::accent_fill(
painter,
rect,
CornerRadius::same((size / 2.0).round() as u8),
lift,
);
let centre = rect.center();
if playback.paused {
// A triangle's centroid is a third of the way from base to apex, not
// half — so centring its bounding box puts the visual mass left of the
// circle's centre, which is the usual reason a play button looks wrong.
// Placed by centroid instead: base at `centre.x - width / 3`.
let (width, height) = (size * 0.30, size * 0.34);
let base = centre.x - width / 3.0;
painter.add(egui::Shape::convex_polygon(
vec![
pos2(base, centre.y - height / 2.0),
pos2(base, centre.y + height / 2.0),
pos2(base + width, centre.y),
],
theme::on_accent(),
Stroke::NONE,
));
} else {
// Pause is symmetric, so it is centred the ordinary way.
let (bar, height, gap) = (size * 0.09, size * 0.32, size * 0.10);
for side in [-1.0f32, 1.0] {
let x = centre.x + side * (gap / 2.0 + bar / 2.0);
painter.rect_filled(
Rect::from_center_size(pos2(x, centre.y), Vec2::new(bar, height)),
CornerRadius::same((bar / 2.0).round() as u8),
theme::on_accent(),
);
}
}
if response
.on_hover_text(if playback.paused {
"Play (Space)"
} else {
@@ -232,7 +298,7 @@ fn times(ui: &mut egui::Ui, playback: &Playback, over_picture: bool) {
let (size, ink, dim) = if over_picture {
(13.0, Color32::WHITE, Color32::from_white_alpha(215))
} else {
(12.0, theme::MUTED, theme::MUTED)
(12.0, theme::muted(), theme::muted())
};
let time = |text: String| egui::RichText::new(text).size(size).color(ink);
@@ -413,7 +479,11 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
if let Some(progress) = playback.progress() {
let mut played = track;
played.set_width(track.width() * progress);
painter.rect_filled(played, CornerRadius::same(3), theme::ACCENT);
// The gradient is sized to the *played* part, not to the whole bar, so
// the far end of it arrives at the playhead rather than at the end of
// the file. A bar that only ever shows the first fifth of its own ramp
// does not read as a gradient at all.
theme::accent_fill(painter, played, CornerRadius::same(3), 0.0);
painter.circle_filled(
egui::pos2(played.right(), track.center().y),
if hovered { 8.0 } else { 6.0 },
+14
View File
@@ -21,3 +21,17 @@ directories.workspace = true
rusqlite.workspace = true
keyring.workspace = true
sha2.workspace = true
# The credential store, per platform. Without the target's feature `keyring`
# compiles fine and silently resolves to its in-memory `mock` store, which
# accepts every write and has forgotten it by the next `Entry` — a share that
# adds cleanly and then reports "has no stored credentials" on open. These
# blocks only add features to the dependency above.
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd"))'.dependencies]
keyring = { workspace = true, features = ["sync-secret-service", "crypto-rust"] }
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { workspace = true, features = ["windows-native"] }
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
keyring = { workspace = true, features = ["apple-native"] }
+212
View File
@@ -0,0 +1,212 @@
//! What the window wears.
//!
//! Only the *choice* lives here — the colours themselves are `pstr-app`'s, and
//! nothing outside the window has an opinion about them. This is stored beside
//! the playback preferences and read the same way: a file that will not parse is
//! a line in the log and a fall back to the defaults, because a broken theme
//! file must never be a reason the library does not open.
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
/// A palette family: Catppuccin's four, plus the one this app shipped with.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Flavor {
/// Near-black and Proton purple. The default, and the only one that is not
/// somebody else's palette.
#[default]
Proton,
/// Catppuccin Latte — the light one.
Latte,
Frappe,
Macchiato,
Mocha,
}
impl Flavor {
pub const ALL: [Self; 5] = [
Self::Proton,
Self::Mocha,
Self::Macchiato,
Self::Frappe,
Self::Latte,
];
pub fn label(self) -> &'static str {
match self {
Self::Proton => "Proton",
Self::Latte => "Catppuccin Latte",
Self::Frappe => "Catppuccin Frappé",
Self::Macchiato => "Catppuccin Macchiato",
Self::Mocha => "Catppuccin Mocha",
}
}
pub fn description(self) -> &'static str {
match self {
Self::Proton => "Near-black, so the artwork is the only bright thing on screen",
Self::Latte => "Light. The only one that is",
Self::Frappe => "Dark, warm, the lowest contrast of the three",
Self::Macchiato => "Dark, between Frappé and Mocha",
Self::Mocha => "Dark, the deepest of Catppuccin's three",
}
}
/// Whether this flavour is a light theme. Latte, and only Latte.
///
/// It decides more than the colours: egui keeps a style per theme and the
/// platform draws the title bar from the window's own, so both have to be
/// told which of the two this is.
pub fn is_light(self) -> bool {
matches!(self, Self::Latte)
}
}
/// The one strong colour in the window — and, where a gradient is drawn, the
/// hue it runs into.
///
/// Named for the first hue rather than for the pair, except where the pair *is*
/// the point: [`Accent::PinkSky`] is a gradient before it is a colour.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Accent {
/// Proton purple, and the equivalent hue in every Catppuccin flavour.
#[default]
Mauve,
Pink,
Sky,
/// Pink running into light blue.
PinkSky,
Lavender,
Blue,
Teal,
Peach,
}
impl Accent {
pub const ALL: [Self; 8] = [
Self::Mauve,
Self::Pink,
Self::Sky,
Self::PinkSky,
Self::Lavender,
Self::Blue,
Self::Teal,
Self::Peach,
];
pub fn label(self) -> &'static str {
match self {
Self::Mauve => "Mauve",
Self::Pink => "Pink",
Self::Sky => "Sky",
Self::PinkSky => "Pink → Sky",
Self::Lavender => "Lavender",
Self::Blue => "Blue",
Self::Teal => "Teal",
Self::Peach => "Peach",
}
}
}
/// The whole of the choice.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
// An older or hand-edited file is missing fields rather than invalid.
#[serde(default)]
pub struct Appearance {
pub flavor: Flavor,
pub accent: Accent,
/// Whether the accent is painted as a gradient — the seek bar, the play
/// button, the top bar — or flat.
///
/// A switch rather than a fixed decision because a gradient is the first
/// thing to go wrong on a screen that cannot show it: on 6-bit panels a
/// slow ramp across a wide bar bands visibly, and flat is better than
/// striped.
pub gradients: bool,
}
impl Default for Appearance {
fn default() -> Self {
Self {
flavor: Flavor::default(),
accent: Accent::default(),
gradients: true,
}
}
}
fn appearance_file(dirs: &AppDirs) -> std::path::PathBuf {
dirs.config.join("appearance.json")
}
/// The stored choice, or the defaults on a first run.
pub fn load(dirs: &AppDirs) -> Result<Appearance> {
Ok(read_json::<Appearance>(&appearance_file(dirs))?.unwrap_or_default())
}
/// Write the choice.
pub fn save(dirs: &AppDirs, appearance: &Appearance) -> Result<()> {
write_json(&appearance_file(dirs), appearance)
}
#[cfg(test)]
mod tests {
use super::*;
fn dirs(root: &std::path::Path) -> AppDirs {
AppDirs {
config: root.to_path_buf(),
data: root.to_path_buf(),
cache: root.to_path_buf(),
}
}
#[test]
fn a_first_run_gets_the_palette_the_app_shipped_with() {
let appearance = Appearance::default();
assert_eq!(appearance.flavor, Flavor::Proton);
assert_eq!(appearance.accent, Accent::Mauve);
assert!(appearance.gradients);
}
#[test]
fn what_was_saved_is_what_loads() {
let temp = std::env::temp_dir().join(format!("pstr-appearance-{}", std::process::id()));
std::fs::create_dir_all(&temp).unwrap();
let dirs = dirs(&temp);
let appearance = Appearance {
flavor: Flavor::Mocha,
accent: Accent::PinkSky,
gradients: false,
};
save(&dirs, &appearance).unwrap();
assert_eq!(load(&dirs).unwrap(), appearance);
std::fs::remove_dir_all(&temp).ok();
}
#[test]
fn a_file_written_before_a_field_existed_keeps_the_rest() {
let temp = std::env::temp_dir().join(format!("pstr-appearance-old-{}", std::process::id()));
std::fs::create_dir_all(&temp).unwrap();
let dirs = dirs(&temp);
std::fs::write(appearance_file(&dirs), r#"{"flavor":"mocha"}"#).unwrap();
let appearance = load(&dirs).unwrap();
assert_eq!(appearance.flavor, Flavor::Mocha);
assert_eq!(appearance.accent, Accent::default());
assert!(appearance.gradients);
std::fs::remove_dir_all(&temp).ok();
}
#[test]
fn only_latte_is_a_light_theme() {
for flavor in Flavor::ALL {
assert_eq!(flavor.is_light(), flavor == Flavor::Latte);
}
}
}
+121 -6
View File
@@ -21,7 +21,7 @@ use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId,
use crate::naming::{self, ParsedName};
/// Bump when adding a migration.
pub const SCHEMA_VERSION: i64 = 4;
pub const SCHEMA_VERSION: i64 = 5;
const MIGRATION_V1: &str = r#"
CREATE TABLE nodes (
@@ -118,6 +118,16 @@ CREATE TABLE episode_metadata (
);
"#;
/// Which rows the viewer chose themselves.
///
/// A column rather than a separate table of overrides, because it changes
/// nothing about what the row *holds* — only about who put it there, and
/// therefore whether a match run may replace it. See
/// [`MetadataRecord::manual`].
const MIGRATION_V5: &str = r#"
ALTER TABLE title_metadata ADD COLUMN manual INTEGER NOT NULL DEFAULT 0;
"#;
/// What `season = -1` means in `episode_metadata`: no season at all.
const ABSOLUTE_SEASON: i64 = -1;
@@ -219,6 +229,13 @@ impl Catalog {
tx.commit()?;
}
if version < 5 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V5)?;
tx.pragma_update(None, "user_version", 5)?;
tx.commit()?;
}
Ok(())
}
@@ -384,7 +401,8 @@ impl Catalog {
pub fn all_metadata(&self) -> Result<HashMap<String, MetadataRecord>> {
let mut statement = self.conn.prepare(
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
manual
FROM title_metadata",
)?;
let rows = statement.query_map([], row_to_metadata)?;
@@ -404,7 +422,8 @@ impl Catalog {
.conn
.query_row(
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes,
url, manual
FROM title_metadata WHERE title_key = ?1",
params![title_key],
row_to_metadata,
@@ -422,8 +441,9 @@ impl Catalog {
self.conn.execute(
"INSERT INTO title_metadata (
title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
manual
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
ON CONFLICT (title_key) DO UPDATE SET
provider = excluded.provider,
matched = excluded.matched,
@@ -439,7 +459,8 @@ impl Catalog {
rating = excluded.rating,
genres = excluded.genres,
episodes = excluded.episodes,
url = excluded.url",
url = excluded.url,
manual = excluded.manual",
params![
record.title_key,
record.provider.as_str(),
@@ -457,11 +478,31 @@ impl Catalog {
data.map(|data| data.genres.join("\n")),
data.and_then(|data| data.episodes),
data.and_then(|data| data.url.as_deref()),
record.manual as i64,
],
)?;
Ok(())
}
/// Forget one title's answer entirely, episodes included.
///
/// Different from storing a miss: a miss says "asked, nothing there" and is
/// trusted for a while, where this leaves no row at all and the next match
/// run treats the title as one it has never seen. That is what "match this
/// one automatically again" has to mean after a hand-picked match, since a
/// hand-picked one never expires.
pub fn forget_metadata(&self, title_key: &str) -> Result<()> {
self.conn.execute(
"DELETE FROM title_metadata WHERE title_key = ?1",
params![title_key],
)?;
self.conn.execute(
"DELETE FROM episode_metadata WHERE title_key = ?1",
params![title_key],
)?;
Ok(())
}
/// Forget every stored answer.
///
/// What "match again" and "turn enrichment off" both do. Deliberately not
@@ -618,6 +659,7 @@ fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataR
provider,
metadata,
fetched_at,
manual: row.get::<_, i64>(16)? != 0,
}))
}
@@ -893,6 +935,7 @@ mod tests {
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1_700_000_000,
manual: false,
};
catalog.set_metadata(&record).expect("store");
@@ -907,6 +950,76 @@ mod tests {
assert_eq!(catalog.all_metadata().expect("read all").len(), 1);
}
/// A hand-picked match has to read back as hand-picked, or the next match
/// run overwrites the one thing the viewer corrected themselves.
#[test]
fn a_hand_picked_match_says_so_when_it_is_read_back() {
let catalog = Catalog::in_memory().expect("open");
let record = MetadataRecord {
title_key: "fate stay night heavens feel".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1_700_000_000,
manual: true,
};
catalog.set_metadata(&record).expect("store");
let read = catalog.metadata(&record.title_key).expect("read");
assert_eq!(read.as_ref().map(|read| read.manual), Some(true));
assert_eq!(read, Some(record.clone()));
// And an automatic write over the top says so in turn: the flag is the
// row's, not the title's.
catalog
.set_metadata(&MetadataRecord {
manual: false,
..record.clone()
})
.expect("store again");
assert_eq!(
catalog
.metadata(&record.title_key)
.expect("read")
.map(|read| read.manual),
Some(false)
);
}
/// Forgetting is not the same as storing a miss: it must leave no row at
/// all, so the next match run treats the title as one it never asked about.
#[test]
fn forgetting_a_title_leaves_neither_its_match_nor_its_episodes() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.set_metadata(&MetadataRecord {
title_key: "attack on titan".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1,
manual: true,
})
.expect("store");
catalog
.set_episode_metadata(
"attack on titan",
ProviderId::AniList,
1,
&[EpisodeMetadata {
season: None,
number: 1,
name: Some("To You, in 2000 Years".into()),
overview: None,
still_url: None,
air_date: None,
}],
)
.expect("store episodes");
catalog.forget_metadata("attack on titan").expect("forget");
assert!(catalog.metadata("attack on titan").expect("read").is_none());
assert!(catalog.all_episode_metadata().expect("read").is_empty());
}
/// The negative answer is the one that has to survive a round trip: without
/// it every render re-asks about every unmatched title.
#[test]
@@ -917,6 +1030,7 @@ mod tests {
provider: ProviderId::AniList,
metadata: None,
fetched_at: 1_700_000_000,
manual: false,
};
catalog.set_metadata(&record).expect("store");
@@ -959,6 +1073,7 @@ mod tests {
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1,
manual: false,
})
.expect("store");
+1
View File
@@ -5,6 +5,7 @@
//! how a share is opened and how its contents are modelled.
#![forbid(unsafe_code)]
pub mod appearance;
pub mod catalog;
pub mod config;
pub mod error;
+33 -3
View File
@@ -162,6 +162,19 @@ impl Title {
self.episodes().filter(|e| e.is_watched()).count()
}
/// Whether the files themselves say this is episodic, rather than
/// [`Title::kind`] having been inferred from how many of them there are.
///
/// The difference matters to whoever is matching this against a provider: a
/// folder of three unnumbered films is a `Series` here purely because it
/// holds three files, and it must not be scored down against the film
/// entries it is actually made of. Numbering is a statement; a file count
/// is a guess.
pub fn states_its_numbering(&self) -> bool {
self.episodes()
.any(|episode| episode.node.parsed.is_episode() || episode.node.parsed.season.is_some())
}
/// The file to pull a poster thumbnail from: the first episode there is.
/// Proton renders a thumbnail per file, so any of them is a frame of the
/// right show; the first is the one most likely already cached.
@@ -288,9 +301,7 @@ impl Library {
for title in &mut titles {
// A season number, an episode number or more than one file all mean
// series. A lone unnumbered file is a film.
let numbered = title.episodes().any(|episode| {
episode.node.parsed.is_episode() || episode.node.parsed.season.is_some()
});
let numbered = title.states_its_numbering();
title.kind = if numbered || title.episode_count() > 1 {
TitleKind::Series
} else {
@@ -357,10 +368,19 @@ impl Library {
/// The grouping key: case- and punctuation-insensitive, so `Fullmetal
/// Alchemist: Brotherhood` and `Fullmetal Alchemist Brotherhood` are one title.
///
/// An apostrophe is the one punctuation mark that closes up rather than
/// separating: `Heaven's Feel` keys as `heavens feel`, which is what a folder
/// that had to give its apostrophe up to the filesystem is called. Splitting it
/// into `heaven s feel` instead files the two apart and, worse, costs the
/// matcher a real match on the difference.
pub fn title_key(title: &str) -> String {
let mut key = String::with_capacity(title.len());
let mut pending_space = false;
for character in title.chars() {
if matches!(character, '\'' | '\u{2019}' | '\u{02bc}') {
continue;
}
if character.is_alphanumeric() {
if pending_space && !key.is_empty() {
key.push(' ');
@@ -635,6 +655,16 @@ mod tests {
assert_eq!(title_key("K-On!!"), "k on");
}
/// An apostrophe closes up rather than separating, and a name written to
/// disk has usually lost it — the two have to key alike, or a folder called
/// `Heavens Feel` is a different title to the one the provider has.
#[test]
fn an_apostrophe_keys_the_same_as_its_absence() {
assert_eq!(title_key("Heaven's Feel"), "heavens feel");
assert_eq!(title_key("Heaven\u{2019}s Feel"), title_key("Heavens Feel"));
assert_eq!(title_key("Devils' Line"), title_key("Devils Line"));
}
#[test]
fn folders_are_not_episodes() {
let mut folder = node("s", "1", "Show", None, None);
+30
View File
@@ -250,6 +250,13 @@ pub struct MetadataRecord {
pub metadata: Option<TitleMetadata>,
/// Unix seconds. What makes a miss expire rather than being forever.
pub fetched_at: i64,
/// The viewer picked this entry themselves.
///
/// A hand-picked match outranks anything the matcher would decide: it never
/// expires, and "match again" leaves it alone. Otherwise the one title the
/// scorer gets wrong — the case this flag exists for — would be corrected by
/// hand and then quietly un-corrected by the next run.
pub manual: bool,
}
/// How long a stored answer is trusted.
@@ -267,6 +274,11 @@ impl MetadataRecord {
if self.provider != provider {
return false;
}
// A hand-picked match does not go stale: nothing a re-ask could learn
// would be better than what the viewer already said it was.
if self.manual {
return true;
}
let ttl = if self.metadata.is_some() {
MATCH_TTL_SECS
} else {
@@ -425,6 +437,7 @@ mod tests {
provider: ProviderId::AniList,
metadata: None,
fetched_at: now - MISS_TTL_SECS - 1,
manual: false,
};
assert!(!miss.is_fresh(now, ProviderId::AniList));
@@ -435,6 +448,22 @@ mod tests {
assert!(matched.is_fresh(now, ProviderId::AniList));
}
/// What the viewer picked by hand outlives every TTL: it is not an answer
/// that could go stale, it is an instruction.
#[test]
fn a_hand_picked_match_never_expires() {
let record = MetadataRecord {
title_key: "k".into(),
provider: ProviderId::AniList,
metadata: Some(metadata()),
fetched_at: 0,
manual: true,
};
assert!(record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::AniList));
// But it is still one provider's id, and it means nothing to the other.
assert!(!record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::Tmdb));
}
/// Switching provider must re-ask rather than trust the other one's answer —
/// especially its silence.
#[test]
@@ -444,6 +473,7 @@ mod tests {
provider: ProviderId::AniList,
metadata: Some(metadata()),
fetched_at: 1_000_000_000,
manual: false,
};
assert!(!record.is_fresh(1_000_000_000, ProviderId::Tmdb));
}
+45 -4
View File
@@ -345,9 +345,7 @@ fn ordinal_before(lower: &str, at: usize) -> Option<(u32, usize)> {
.into_iter()
.find_map(|suffix| head.strip_suffix(suffix))?;
let digits_start = head
.rfind(|c: char| !c.is_ascii_digit())
.map_or(0, |index| index + 1);
let digits_start = trailing_digits_start(head).unwrap_or(0);
let digits = &head[digits_start..];
if digits.is_empty() || digits.len() > 2 {
return None;
@@ -596,6 +594,26 @@ fn read_number(text: &str, from: usize) -> Option<(u32, usize)> {
text[from..end].parse().ok().map(|n| (n, end))
}
/// Where the trailing run of ASCII digits starts, or `None` when the text is
/// nothing but digits (or empty).
///
/// Walked back by `char_indices`, not by `rfind(…) + 1`: one past the *start*
/// of the character before the digits is inside it whenever that character is
/// multi-byte. `Death (True)² - 007` is the case that proves it — the `+ 1`
/// form cuts the `²` in half and panics.
fn trailing_digits_start(text: &str) -> Option<usize> {
let mut start = None;
for (index, ch) in text.char_indices().rev() {
if ch.is_ascii_digit() {
start = Some(index);
} else {
return Some(start.unwrap_or(text.len()));
}
}
None
}
fn is_token_char(byte: Option<u8>) -> bool {
byte.is_some_and(|b| b.is_ascii_alphanumeric())
}
@@ -715,7 +733,7 @@ pub fn classify_folder(name: &str) -> FolderRole {
/// `Evangelion 3`, `Kaijuu 8-gou` — would lose it to a season it never claimed.
fn trailing_season(name: &str) -> Option<(&str, u32)> {
let trimmed = name.trim_end();
let digits_start = trimmed.rfind(|c: char| !c.is_ascii_digit())? + 1;
let digits_start = trailing_digits_start(trimmed)?;
let (head, digits) = trimmed.split_at(digits_start);
let season: u32 = digits.parse().ok()?;
@@ -910,6 +928,29 @@ mod tests {
}
}
/// **A panic, not a mis-parse.** A folder name whose trailing digits sit
/// directly behind a multi-byte character — or that simply *ends* in one —
/// used to be split one byte past that character's start, i.e. inside it.
#[test]
fn a_multi_byte_character_does_not_break_folder_classification() {
for name in [
"Neon Genesis Evangelion Death (True)\u{b2}",
"Show\u{b2}3",
"Show\u{b2}3rd Season",
"Caf\u{e9} Season 2",
"\u{b2}",
] {
// The classification itself is the assertion: any of these used to
// panic before it could return one.
let _ = classify_folder(name);
}
let FolderRole::Title(parsed) = classify_folder("Caf\u{e9} Season 2") else {
panic!("expected a title");
};
assert_eq!((parsed.title.as_str(), parsed.season), ("Café", Some(2)));
}
/// A resolution is four digits in brackets too, and must not read as a year.
#[test]
fn a_bracketed_resolution_is_not_mistaken_for_a_year() {
+3
View File
@@ -14,6 +14,9 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
# The per-platform keystore feature comes from `pstr-core` above — cargo unifies
# it across the graph, and `pstr-core` is a hard dependency here. Do not enable
# `keyring` without it: the feature-less build resolves to the `mock` store.
keyring.workspace = true
# Same feature set as the SDK's, deliberately: two reqwest configurations in one
# binary means two TLS stacks linked into it.
+106 -3
View File
@@ -85,19 +85,28 @@ impl Provider for AniList {
}
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
// Each term is asked only because the one before it came back with
// nothing at all — see `search_terms`. A page of candidates, however
// weak, is the matcher's business rather than this one's.
for term in search_terms(&query.name) {
let data: Option<SearchData> = self
.query(serde_json::json!({
"query": SEARCH,
"variables": { "search": query.name, "perPage": PAGE_SIZE },
"variables": { "search": term, "perPage": PAGE_SIZE },
}))
.await?;
Ok(data
let candidates: Vec<Candidate> = data
.map(|data| data.page.media)
.unwrap_or_default()
.into_iter()
.map(Media::into_candidate)
.collect())
.collect();
if !candidates.is_empty() {
return Ok(candidates);
}
}
Ok(Vec::new())
}
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
@@ -157,6 +166,61 @@ impl AniList {
}
}
/// The search strings to try, in order, until one of them returns anything.
///
/// AniList does not do fuzzy search. Every word of the query has to be a word
/// its index actually holds — only the *last* one is matched as a prefix — and
/// all of them have to match, so a single word AniList has never seen empties
/// the whole result set. `ghost in the shel` finds the film; `cowbo bebop`
/// finds nothing.
///
/// That turns one filesystem habit into a total miss: a name written to disk
/// has had its apostrophes taken out, because plenty of tools and shares still
/// dislike them. `Fate/stay night [Heaven's Feel]` indexes the words `heaven`
/// and `s`, and the folder is called `Fate stay night Heavens Feel` — whose
/// `heavens` is not a word in the index and not a prefix of one either. AniList
/// answers with an empty page, the title is stored as a miss, and it stays
/// unmatched for as long as the record is fresh.
///
/// So the fallback puts the apostrophe back the only way that survives
/// tokenisation: by dropping the `s` it was holding on to. It is asked for only
/// after the name as written found nothing, which is what keeps it from
/// broadening a search that was already working.
fn search_terms(name: &str) -> Vec<String> {
let mut terms = vec![name.to_string()];
let depossessed = without_possessive_s(name);
if depossessed != name {
terms.push(depossessed);
}
terms
}
/// `Heavens Feel` → `Heaven Feel`.
///
/// Only words long enough to still mean something without it, and only where
/// the `s` follows a letter — a word that kept its apostrophe (`Heaven's`) is
/// already what the index holds, and cutting the `s` off it would leave the
/// apostrophe behind as its own token.
fn without_possessive_s(name: &str) -> String {
name.split(' ')
.map(|word| {
let mut characters = word.chars().rev();
let last = characters.next();
let previous = characters.next();
let long_enough = word.chars().count() >= 4;
match (last, previous) {
(Some('s' | 'S'), Some(previous)) if long_enough && previous.is_alphabetic() => {
&word[..word.len() - 1]
}
_ => word,
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[derive(Deserialize)]
struct Response<T> {
data: Option<T>,
@@ -402,6 +466,45 @@ mod tests {
assert_eq!(kind_of(None), TitleKind::Film);
}
/// The miss this exists for: a folder named after a title whose apostrophe
/// the filesystem never got, searched against an index that tokenised the
/// apostrophe into a word boundary.
#[test]
fn a_name_that_lost_its_apostrophe_is_searched_for_again_without_the_s() {
assert_eq!(
search_terms("Fate stay night Heavens Feel"),
vec![
"Fate stay night Heavens Feel".to_string(),
"Fate stay night Heaven Feel".to_string(),
]
);
}
/// One request, not two, for the names that need no repair — the fallback
/// is a second round trip and a title that already matched must not pay it.
#[test]
fn a_name_with_nothing_to_repair_is_searched_for_once() {
assert_eq!(search_terms("Cowboy Bebop"), vec!["Cowboy Bebop"]);
// Short words keep their `s`: `Kids on the Slope` is not `Kid on the
// Slope`, and a name that still has its apostrophe is already what the
// index holds.
assert_eq!(search_terms("Heaven's Feel"), vec!["Heaven's Feel"]);
assert_eq!(
search_terms("Kids on the Bus"),
vec!["Kids on the Bus".to_string(), "Kid on the Bus".to_string()],
"a four-letter word is long enough; a three-letter one is not"
);
}
/// Byte slicing, on names that are not ASCII.
#[test]
fn a_multi_byte_name_is_not_cut_inside_a_character() {
for name in ["進撃の巨人", "Kaguya-sama: Love is War?", "café"] {
let terms = search_terms(name);
assert_eq!(terms[0], name);
}
}
#[test]
fn markup_becomes_text_and_breaks_become_newlines() {
assert_eq!(
+61 -2
View File
@@ -38,6 +38,15 @@ pub struct Query {
pub name: String,
pub year: Option<u32>,
pub kind: TitleKind,
/// Whether the library *knows* what kind of thing this is.
///
/// The mirror of [`Candidate::kind_known`], and it exists for the same
/// reason. A folder holding the three `Heaven's Feel` films is a `Series`
/// to [`pstr_core::library`] only because it holds three files — the films
/// themselves are numbered nothing — and scoring that guess against the
/// provider's (correct) `Film` costs the right answer the 0.05 that puts it
/// under the floor.
pub kind_known: bool,
}
impl Query {
@@ -46,8 +55,16 @@ impl Query {
name: name.into(),
year,
kind,
kind_known: true,
}
}
/// Say that [`Query::kind`] was inferred rather than stated — see
/// [`Query::kind_known`].
pub fn with_guessed_kind(mut self) -> Self {
self.kind_known = false;
self
}
}
/// One of a provider's answers, with every name it is known by.
@@ -93,10 +110,11 @@ pub fn score(query: &Query, candidate: &Candidate) -> f32 {
_ => {}
}
if candidate.kind_known && query.kind != candidate.metadata.kind {
if query.kind_known && candidate.kind_known && query.kind != candidate.metadata.kind {
// A film and a series of the same name are usually genuinely related —
// an adaptation, a compilation — so this is a nudge, not a veto. And
// only when the provider said; see `Candidate::kind_known`.
// only when both sides actually said; see `Candidate::kind_known` and
// `Query::kind_known`.
score -= 0.05;
}
@@ -353,6 +371,47 @@ mod tests {
assert!(score(&query, &unstated) > score(&query, &stated));
assert_eq!(score(&query, &unstated), 1.0);
// And the same in reverse: a library that guessed its own kind has
// nothing to disagree with the provider about.
let guessed = query.clone().with_guessed_kind();
assert_eq!(score(&guessed, &stated), 1.0);
}
/// **The apostrophe case, both shapes of it.** A folder cannot carry the
/// apostrophe of `Fate/stay night [Heaven's Feel]`, so it is called
/// `Heavens Feel` — and until [`title_key`] closed the apostrophe up rather
/// than splitting on it, the provider's own entry scored under the floor.
///
/// One film per folder matches outright. The trilogy in a single folder is
/// the harder half: the library calls three files a series where the
/// provider calls each of them a film, so it clears the floor by a hair
/// with that mismatch subtracted.
#[test]
fn a_title_that_lost_its_apostrophe_matches_the_entry_that_kept_one() {
let film = candidate(
"Fate/stay night [Heaven's Feel] I. presage flower",
&[],
Some(2017),
TitleKind::Film,
);
let alone = Query::new(
"Fate stay night Heavens Feel I Presage Flower",
Some(2017),
TitleKind::Film,
);
assert!(score(&alone, &film) >= 1.0);
// Three films in one folder: a `Series` only because there are three of
// them, so the kind is a guess and costs the film entry nothing.
let trilogy =
Query::new("Fate stay night Heavens Feel", None, TitleKind::Series).with_guessed_kind();
assert!(
score(&trilogy, &film) >= MATCH_FLOOR,
"scored {}",
score(&trilogy, &film)
);
}
/// The tie-break must not override the score: a later candidate that
+55 -2
View File
@@ -22,7 +22,7 @@
use std::sync::Arc;
use pstr_core::library::{Title, title_key};
use pstr_core::library::{Title, TitleKind, title_key};
use pstr_core::metadata::{
EpisodeMetadata, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
};
@@ -124,7 +124,12 @@ impl MetadataService {
/// `Ok(None)` is a real answer — nothing matched well enough — and the
/// caller should store it. An `Err` is not, and the caller should not.
pub async fn lookup(&self, title: &Title) -> Result<Option<TitleMetadata>> {
let query = Query::new(title.name.clone(), title.year, title.kind);
let mut query = Query::new(title.name.clone(), title.year, title.kind);
// Nothing in the files numbered itself, so the kind came from counting
// them — see `Query::kind_known`.
if !title.states_its_numbering() {
query = query.with_guessed_kind();
}
let candidates = self.source.search(&query).await?;
let found = candidates.len();
let best = matching::best(&query, candidates);
@@ -149,9 +154,56 @@ impl MetadataService {
provider: self.provider(),
metadata,
fetched_at: now(),
manual: false,
})
}
/// Everything the provider thinks `name` might be, unscored and in its own
/// order.
///
/// The escape hatch from [`matching::best`], for a viewer picking an entry
/// by hand. Nothing is filtered here and nothing is ranked: the floor exists
/// to stop the *matcher* guessing, and a person reading the list is not
/// guessing. The `Fate/stay night [Heaven's Feel]` trilogy is the case that
/// wants it — three films in one folder, matched against a provider that
/// files each of them separately, where no single entry is the right answer
/// and only the viewer knows which one they meant.
///
/// Takes a kind because the providers key their search on it, and no year:
/// the point of a hand search is that the library's own guesses are what
/// went wrong.
pub async fn search(&self, name: &str, kind: TitleKind) -> Result<Vec<TitleMetadata>> {
let query = Query::new(name.trim().to_string(), None, kind).with_guessed_kind();
if query.name.is_empty() {
return Ok(Vec::new());
}
let candidates = self.source.search(&query).await?;
tracing::debug!(
"hand search {:?}: {} candidates from {}",
query.name,
candidates.len(),
self.provider().label()
);
Ok(candidates
.into_iter()
.map(|candidate| candidate.metadata)
.collect())
}
/// A record for an entry the viewer picked themselves.
///
/// Marked [`MetadataRecord::manual`], which is what keeps the next match run
/// — including a forced one — from undoing it.
pub fn chosen(&self, title_key: String, found: TitleMetadata) -> MetadataRecord {
MetadataRecord {
title_key,
provider: self.provider(),
metadata: Some(found),
fetched_at: now(),
manual: true,
}
}
/// What the provider lists as the episodes of a title it matched.
///
/// Only ever called for a title that already has a match, because the
@@ -339,6 +391,7 @@ mod tests {
provider: ProviderId::AniList,
metadata: None,
fetched_at: now(),
manual: false,
};
assert!(is_usable(Some(&record), ProviderId::AniList));
assert!(!is_usable(Some(&record), ProviderId::Tmdb));
+18
View File
@@ -111,6 +111,24 @@ tagged with that season. The absolute fallback in `EpisodeGuide::get` stops at
season one for the same reason: without that it answered `S02E01` with season
one's episode one, which is not a missing caption but a wrong one.
Done: **matching a title by hand.** `MATCH_FLOOR` is set where a wrong poster
costs more than none, and the price of that is a few titles it refuses to decide
about — `Fate/stay night [Heaven's Feel]` is the standing example, three films in
one folder against a provider that files each of them as its own entry, so there
is no single answer for the scorer to find. *Change match* on the title page opens
`ui::matcher`: the provider's own answers for whatever is typed, unscored and in
its own order (`MetadataService::search`), and a click pins one. Nothing is
ranked there on purpose — the floor exists to stop the *scorer* guessing, and a
person reading eight entries with their posters is not guessing.
A chosen row is stored `manual` (schema **v5**), which means two things:
`MetadataRecord::is_fresh` never expires it, and `run_match` skips it even when
forced — "match again" must not undo the one title someone fixed by hand. The
way back is *Clear*, which drops the row entirely (`Catalog::forget_metadata`)
rather than storing a miss, so the next run treats the title as one it has never
asked about. Switching provider still clears everything, hand-picked rows
included: a `remote_id` means nothing to the other provider.
Worth knowing before chasing a missing name: AniList's episode titles come from
`streamingEpisodes`, which is what streaming sites published, and for some shows
it is simply **empty** — all three Oshi no Ko entries have none, while Fullmetal
+1 -1
View File
@@ -133,7 +133,7 @@ almost every field in it is computed.
- **The `[patch.crates-io]` block in `Cargo.toml` is development only.** It points
at `../proton-sdk-rs`, so both build scripts refuse to start when it is present
and that checkout is missing. A release must build against the published
crates — drop the block once proton-sdk 0.3.3 is on crates.io.
crates — drop the block once the pinned proton-sdk is on crates.io.
- **There is no `LICENSE` file in the repository root** even though `Cargo.toml`
declares MIT. Every packager warns and ships without the license text; the MSI
falls back to `windows/license.rtf`. Adding the file fixes all of them at once.
+13 -9
View File
@@ -7,7 +7,7 @@
-D Version=0.1.0 -D BinDir=... -D MpvDll=... -D IconFile=...
Why a second file at all, next to proton-stream.wxs
---------------------------------------------------
===================================================
The WiX toolset cannot build an .msi on Linux. Not "is unsupported" — it
fails outright, in WixToolset.Core's BundleValidator.GetCanonicalRelativePath,
which validates every Directory/@Name by prepending a drive letter and asking
@@ -26,7 +26,7 @@
of it — hence this file rather than a preprocessor branch inside the v4 one.
What this loses against the WiX build
-------------------------------------
=====================================
The dialog UI. WixUI_InstallDir, and with it the license page and the
"choose a folder" page, is a WiX extension with no wixl equivalent, so this
.msi installs with the plain Windows Installer progress UI and always lands in
@@ -35,7 +35,7 @@
entry, clean uninstall — is authored here and identical.
Keep in sync with proton-stream.wxs
-----------------------------------
===================================
UpgradeCode and every Component Guid are shared with that file, deliberately.
They are what lets an .msi built here upgrade one built on Windows (and the
reverse) instead of the two stacking up or corrupting each other's component
@@ -89,17 +89,21 @@
<Component Id="AppExe" Guid="623c8cb8-b826-4d57-a054-e5be56f24c19" Win64="yes">
<File Id="AppExe" Source="$(var.BinDir)/proton-stream.exe"
Name="proton-stream.exe" KeyPath="yes">
<!-- Not advertised: an advertised shortcut needs the feature
state machine that the dialog-less install does not drive. -->
Name="proton-stream.exe" KeyPath="yes" />
<!-- Not advertised: an advertised shortcut needs the feature state
machine that the dialog-less install does not drive. wixl has
no Advertise attribute — it logs "builder.vala: unimplemented"
and writes the advertised form anyway, with the feature name in
Shortcut/Target. What selects the non-advertised form is being
a child of the Component rather than of the File, plus an
explicit formatted Target. Keep it out of the <File>. -->
<Shortcut Id="StartMenuShortcut"
Directory="ProgramMenuFolder"
Name="Proton Stream"
Description="Browse and stream a Proton Drive public link"
Target="[INSTALLDIR]proton-stream.exe"
WorkingDirectory="INSTALLDIR"
Icon="proton-stream.ico"
Advertise="no" />
</File>
Icon="proton-stream.ico" />
</Component>
<!-- The CLI, and the PATH entry that makes it worth installing. Tied
+8
View File
@@ -147,6 +147,14 @@ fetch_mpv() {
resolve_mpv() {
[[ $FETCH_MPV -eq 1 ]] && fetch_mpv
# A previous --fetch-mpv left its unpacked copy in dist/mpv-dev. Adopt it
# rather than making every later run repeat the flag; it is the same
# directory fetch_mpv would have reused.
if [[ -z "$MPV_DEV" && -f "$DIST/mpv-dev/libmpv-2.dll" ]]; then
log "reusing $DIST/mpv-dev"
MPV_DEV="$DIST/mpv-dev"
fi
[[ -n "$MPV_DEV" ]] || die "$(cat <<EOF
no libmpv development files. Either pass --fetch-mpv, or download the mpv-dev
archive from