improved android client, feature parity with desktop

This commit is contained in:
nvrl committed 2026-08-09 16:40:37 +02:00
1 parent 352fdf1933
commit f1b3ad9637
56 files changed
+9727 -1799

No files matched your search

+1 -1
View File
@@ -29,6 +29,7 @@ thiserror.workspace = true
uniffi.workspace = true
serde.workspace = true
serde_json.workspace = true
log = "0.4"
[target.'cfg(target_os = "android")'.dependencies]
# Match rustls-platform-verifier's JNI types: Java objects from different
@@ -36,4 +37,3 @@ serde_json.workspace = true
jni = "0.21.1"
rustls-platform-verifier = "0.6.2"
android_logger = "0.14"
log = "0.4"
File diff suppressed because it is too large. Load diff
+3 -50
View File
@@ -1622,7 +1622,7 @@ impl Engine {
// Only for a title that matched: there is no id to
// ask about otherwise.
let episodes = match &record.metadata {
Some(found) => episodes_of(&service, title, found).await,
Some(found) => service.title_episodes(title, found).await,
None => Vec::new(),
};
Ok((record.title_key.clone(), Some(record), episodes))
@@ -1632,7 +1632,7 @@ impl Engine {
Work::Episodes(title, found) => Ok((
title.key.clone(),
None,
episodes_of(&service, title, found).await,
service.title_episodes(title, found).await,
)),
})
});
@@ -1744,7 +1744,7 @@ impl Engine {
return engine.fail("store the match", error);
}
let episodes = episodes_of(&service, &title, &found).await;
let episodes = service.title_episodes(&title, &found).await;
if !episodes.is_empty() {
let stored = engine.catalog.lock().set_episode_metadata(
&title.key,
@@ -1855,53 +1855,6 @@ enum Work {
Episodes(Title, Box<pstr_core::metadata::TitleMetadata>),
}
/// The episode list for a whole title, with a failure treated as an empty one.
///
/// Deliberately not fatal to the title it belongs to: a poster and a synopsis
/// that arrived are worth keeping even when the episode request was the one
/// that hit the rate limit. The empty result is not cached as an answer — the
/// next match run asks again — because the only thing that triggers one is a
/// viewer pressing the button.
///
/// The match itself only ever answers for *one* entry, and on a provider that
/// files each sequel separately — AniList — that entry is season one. So each
/// further season of the title is searched for by name and its episodes are
/// tagged with the season they came from; without that, seasons two and three
/// have no episode names at all. See
/// [`MetadataService::season_episodes`](pstr_meta::MetadataService::season_episodes).
async fn episodes_of(
service: &MetadataService,
title: &Title,
found: &pstr_core::metadata::TitleMetadata,
) -> Vec<pstr_core::metadata::EpisodeMetadata> {
let mut episodes = service
.episodes(found)
.await
.inspect_err(|error| tracing::warn!("episodes for {}: {error}", found.name))
.unwrap_or_default();
if !service.splits_seasons() {
return episodes;
}
// Season one is what the title's own match already answered for.
let later: Vec<u32> = title
.seasons
.iter()
.filter_map(|season| season.number)
.filter(|number| *number > 1)
.collect();
for season in later {
match service.season_episodes(title, season).await {
Ok(found) => episodes.extend(found),
Err(error) => {
tracing::warn!("episodes for {} season {season}: {error}", title.name);
}
}
}
episodes
}
/// Unix seconds, for the "when was this asked" columns.
fn now() -> i64 {
std::time::SystemTime::now()
+53 -354
View File
@@ -23,11 +23,11 @@ use std::collections::BTreeMap;
use std::sync::Arc;
use egui::{
Color32, CornerRadius, Rect, Rgba, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
Color32, CornerRadius, Rect, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
ThemePreference, ViewportCommand, Visuals,
};
use parking_lot::RwLock;
use pstr_core::appearance::{Accent, Appearance, Flavor};
use pstr_core::appearance::Appearance;
/// Card geometry. The grid is built from these, so a change here moves
/// everything together.
@@ -91,11 +91,12 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
None
}
/// Every colour the app draws with, resolved from a [`Flavor`] and an
/// [`Accent`].
/// Every colour the app draws with, as egui wants them.
///
/// `Copy`, and small, so reading it is a lock and a memcpy rather than anything
/// a drawing loop has to think about.
/// The *choice* of colours is `pstr_core::appearance` — one resolution shared
/// with the Android client, so a flavour means the same thing on both. This is
/// only that palette converted into `Color32`, which is a type `pstr-core`
/// deliberately knows nothing about.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
/// Behind everything: the page.
@@ -118,15 +119,54 @@ pub struct Palette {
/// 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.
/// Ink that stays readable on top of the accent.
pub on_accent: Color32,
pub danger: Color32,
/// Whether this is a light theme.
pub light: bool,
}
const fn color(rgb: pstr_core::appearance::Rgb) -> Color32 {
Color32::from_rgb(rgb.r, rgb.g, rgb.b)
}
impl Palette {
/// The default, spelled out 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::of(pstr_core::appearance::Palette::PROTON);
const fn of(palette: pstr_core::appearance::Palette) -> Self {
Self {
background: color(palette.background),
surface: color(palette.surface),
sunken: color(palette.sunken),
card: color(palette.card),
card_hover: color(palette.card_hover),
border: color(palette.border),
text: color(palette.text),
muted: color(palette.muted),
accent: color(palette.accent),
accent_alt: color(palette.accent_alt),
accent_dim: color(palette.accent_dim),
on_accent: color(palette.on_accent),
danger: color(palette.danger),
light: palette.light,
}
}
/// Resolve a choice into colours.
pub fn resolve(appearance: Appearance) -> Self {
Self::of(pstr_core::appearance::Palette::resolve(appearance))
}
}
/// Blend two colours in linear light, through the shared implementation.
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
let rgb = |c: Color32| pstr_core::appearance::Rgb::new(c.r(), c.g(), c.b());
color(pstr_core::appearance::mix(rgb(from), rgb(to), t))
}
/// Which way a gradient runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
@@ -437,353 +477,10 @@ fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -
/// 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());
@@ -795,11 +492,13 @@ mod tests {
#[test]
fn a_ramp_runs_from_one_colour_to_the_other() {
// The blend itself is `pstr_core::appearance`'s and tested there; what
// is checked here is that the conversion in and out of `Color32` is
// not lossy at the ends.
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));
assert_ne!(mix(from, to, 0.5), from);
}
}
+439
View File
@@ -151,6 +151,395 @@ pub fn save(dirs: &AppDirs, appearance: &Appearance) -> Result<()> {
write_json(&appearance_file(dirs), appearance)
}
/// One colour, 8 bits a channel, with no alpha.
///
/// Deliberately not a front end's colour type: this module resolves a choice
/// into colours for *both* clients, and neither egui's `Color32` nor Compose's
/// `Color` may appear here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
pub const BLACK: Self = Self::new(0, 0, 0);
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
/// Packed as `0xAARRGGBB`, opaque — the form both Android and a hex literal
/// want.
pub const fn argb(self) -> u32 {
0xff00_0000 | ((self.r as u32) << 16) | ((self.g as u32) << 8) | self.b as u32
}
}
/// One channel from sRGB into linear light, as the sRGB transfer function
/// defines it. Blending has to happen here: mixing sRGB values directly darkens
/// the middle of a ramp between saturated hues, which is exactly where a
/// gradient is looked at.
fn linear(channel: u8) -> f32 {
let value = channel as f32 / 255.0;
if value <= 0.04045 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
}
/// Back out of linear light, rounded to a channel.
fn gamma(value: f32) -> u8 {
let value = value.clamp(0.0, 1.0);
let encoded = if value <= 0.003_130_8 {
value * 12.92
} else {
1.055 * value.powf(1.0 / 2.4) - 0.055
};
(encoded * 255.0).round().clamp(0.0, 255.0) as u8
}
/// Blend two colours in linear light.
pub fn mix(from: Rgb, to: Rgb, t: f32) -> Rgb {
let blend = |from: u8, to: u8| gamma(linear(from) * (1.0 - t) + linear(to) * t);
Rgb::new(
blend(from.r, to.r),
blend(from.g, to.g),
blend(from.b, to.b),
)
}
/// Relative luminance, in linear light, as WCAG defines it.
pub fn luminance(color: Rgb) -> f32 {
0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b)
}
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
/// 21.0 for black against white.
fn contrast(one: Rgb, other: Rgb) -> 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.
pub fn worst_contrast(from: Rgb, to: Rgb, ink: Rgb) -> 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.
pub const MIN_CONTRAST: f32 = 3.0;
/// Every colour a client 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: Rgb,
/// The bars above and below it.
pub surface: Rgb,
/// The deepest colour in the flavour — text fields, wells.
pub sunken: Rgb,
/// Tiles, rows, forms.
pub card: Rgb,
pub card_hover: Rgb,
pub border: Rgb,
pub text: Rgb,
pub muted: Rgb,
/// The one strong colour.
pub accent: Rgb,
/// 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: Rgb,
/// The accent taken down into the background, for a pressed control and for
/// selection behind text.
pub accent_dim: Rgb,
/// 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: Rgb,
pub danger: Rgb,
/// Whether this is a light theme.
pub light: bool,
}
/// 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: Rgb,
mantle: Rgb,
crust: Rgb,
surface0: Rgb,
surface1: Rgb,
subtext0: Rgb,
text: Rgb,
pink: Rgb,
mauve: Rgb,
sky: Rgb,
sapphire: Rgb,
blue: Rgb,
lavender: Rgb,
teal: Rgb,
green: Rgb,
peach: Rgb,
yellow: Rgb,
red: Rgb,
light: bool,
}
const fn rgb(hex: u32) -> Rgb {
Rgb::new(
((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) -> (Rgb, Rgb) {
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.
pub 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: Rgb| {
if ramp.light {
mix(color, Rgb::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, Rgb::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::*;
@@ -209,4 +598,54 @@ mod tests {
assert_eq!(flavor.is_light(), flavor == Flavor::Latte);
}
}
#[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 a_blend_stays_between_the_two_colours_it_blends() {
let (from, to) = (Rgb::new(0, 0, 0), Rgb::new(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));
}
}
+10
View File
@@ -227,6 +227,16 @@ impl Catalog {
Ok(catalog)
}
/// How many rows this connection has written since it was opened.
///
/// A cache key, not a statistic: anything derived from the catalog stays
/// valid exactly as long as this does not move. SQLite counts it for us, so
/// no write path has to remember to invalidate anything — which is the
/// failure mode a hand-maintained counter has.
pub fn writes(&self) -> u64 {
self.conn.total_changes()
}
fn migrate(&mut self) -> Result<()> {
let version: i64 = self
.conn
+483
View File
@@ -0,0 +1,483 @@
//! The chapters a file was muxed with, and what they are for.
//!
//! Anime releases mark their opening and ending as chapters, which is the whole
//! reason this exists: with them a player can offer to skip the ninety seconds
//! everyone skips, and without them it cannot.
//!
//! Nothing here talks to a player. Reading the chapter list out of mpv is
//! `pstr-player`'s job on the desktop and the JNI adapter's on Android; both
//! hand the result to [`roles`], so a chapter means the same thing on either.
/// One chapter of the file.
#[derive(Debug, Clone, PartialEq)]
pub struct Chapter {
/// Position in the list, which is what mpv's `chapter` property is set to.
pub index: i64,
/// The name the muxer gave it, if any.
pub title: Option<String>,
/// Where it starts, in seconds.
pub start: f64,
}
impl Chapter {
/// What to show in a list. A nameless chapter is still worth an entry —
/// it is a place in the file — so it gets its number.
pub fn label(&self) -> String {
match &self.title {
Some(title) => title.clone(),
None => format!("Chapter {}", self.index + 1),
}
}
/// What this chapter is, from its name alone.
///
/// Prefer [`roles`], which resolves the names that only mean one thing *in
/// context* — a chapter called `Intro` is an opening in one release and the
/// first ten minutes of the story in another.
pub fn role(&self) -> ChapterRole {
match Claim::of(self.title.as_deref().unwrap_or_default()) {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
// Unresolved without the rest of the file: assume it is content,
// which is the reading that never skips anything.
_ => ChapterRole::Content,
}
}
}
/// The kinds of chapter worth treating differently from "part of the episode".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChapterRole {
/// The opening titles.
Opening,
/// The ending titles.
Ending,
/// A preview of the next episode, after the ending.
Preview,
/// Anything else, including everything unnamed.
Content,
}
impl ChapterRole {
/// What a button offering to skip this should say. `None` for a chapter
/// nobody wants skipped.
pub fn skip_label(self) -> Option<&'static str> {
match self {
Self::Opening => Some("Skip opening"),
Self::Ending => Some("Skip ending"),
Self::Preview => Some("Skip preview"),
Self::Content => None,
}
}
/// Whether this is something the file could end on without the viewer
/// missing anything: credits and a trailer for next week.
///
/// An *opening* is not, even though it is skippable — a file does not end
/// with one, and treating it as tail would make a mis-tagged chapter
/// auto-advance out of the episode.
pub fn ends_the_episode(self) -> bool {
matches!(self, Self::Ending | Self::Preview)
}
}
/// What a chapter's *name* claims, before the rest of the file is consulted.
///
/// The two `Maybe` variants are the whole reason this is separate from
/// [`ChapterRole`]. `Intro` is the opening in one release and, in the first
/// episode of Oshi no Ko, a ten-minute introduction to the series that a viewer
/// must not be skipped out of. `Credits` and `Cast` are the ending in most
/// files and a mid-episode scene in a few. Neither can be settled by the name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Claim {
Opening,
Ending,
Preview,
/// Reads like an opening, but only if the file has no real one and it is
/// the length of a theme song.
MaybeOpening,
/// Reads like credits, but only near the end of the file or at theme
/// length.
MaybeEnding,
Content,
}
/// How long an opening or ending theme runs.
///
/// Anime themes are ninety seconds by convention and rarely stray far. This is
/// the corroboration for a name that could go either way: a chapter called
/// `Intro` that runs eleven minutes is not a theme song, whatever it is called.
const THEME_SECONDS: std::ops::RangeInclusive<f64> = 45.0..=150.0;
/// Credits can run longer than a theme — a full cast roll over a still — so a
/// chapter that only *reads* like an ending gets this much room when it sits at
/// the end of the file.
const CREDITS_SECONDS: std::ops::RangeInclusive<f64> = 30.0..=300.0;
/// An opening that far into the file is not an opening. Cold opens run long in
/// a first episode, so this is generous.
const OPENING_BEFORE_SECONDS: f64 = 480.0;
/// Where the last part of a file starts, as a fraction of it. A chapter that
/// merely reads like credits is taken as credits past this point.
const TAIL_FRACTION: f64 = 0.75;
impl Claim {
/// Read a claim out of a chapter name.
///
/// Matched on whole words from the front, not on substrings: `Operation
/// Briefing` starts with `op` and is not an opening, `Ending Note` is not
/// an ending, and skipping a viewer out of either is a mistake they cannot
/// undo without finding the seek bar. Leading numbering (`01 - OP`, `1.
/// Opening`) is stepped over first, because plenty of muxes carry it.
fn of(title: &str) -> Self {
// Japanese names appear verbatim in releases from JP sources, and they
// are unambiguous where the English ones are not. Checked before the
// word split, which only knows about ASCII and would find no words at
// all in a name written entirely in kana.
if title.contains("オープニング") {
return Self::Opening;
}
if title.contains("エンディング") {
return Self::Ending;
}
if title.contains("予告") {
return Self::Preview;
}
let words = words(title);
let words: Vec<&str> = words.iter().map(String::as_str).collect();
let Some((head, rest)) = words.split_first() else {
return Self::Content;
};
let second = rest.first().copied().unwrap_or_default();
match (*head, second) {
// Two-word forms first: the second word is what tells `next
// episode` from `next`, and `end credits` from `end`.
("next", _) | ("nep", _) => Self::Preview,
("end" | "ending" | "closing", "credits") => Self::Ending,
("cast" | "staff", "roll" | "credits") => Self::Ending,
("opening" | "op", "theme" | "song" | "credits") => Self::Opening,
("ending" | "ed", "theme" | "song") => Self::Ending,
("op" | "opening" | "openning" | "ncop", _) => Self::Opening,
("ed" | "ending" | "nced" | "outro" | "endcard", _) => Self::Ending,
("preview" | "pv" | "trailer" | "teaser" | "yokoku", _) => Self::Preview,
("intro", _) => Self::MaybeOpening,
("credits" | "closing" | "cast" | "staff", _) => Self::MaybeEnding,
_ => Self::Content,
}
}
}
/// A chapter name as lowercase words, with any leading numbering dropped.
///
/// `OP1` and `ED2` keep their tag and lose their number — the digit is part of
/// the tag, not a word — while `02 - Opening` loses its leading `02` entirely.
fn words(title: &str) -> Vec<String> {
let mut words: Vec<String> = title
.trim()
.to_ascii_lowercase()
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|word| !word.is_empty())
.map(|word| {
word.trim_end_matches(|c: char| c.is_ascii_digit())
.to_string()
})
.collect();
// A leading token that was *only* digits is numbering, not a name.
if words.first().is_some_and(String::is_empty) {
words.remove(0);
}
words.retain(|word| !word.is_empty());
words
}
/// The role of every chapter in a file, with the file's own shape taken into
/// account.
///
/// This is the function to use. [`Chapter::role`] reads a name in isolation and
/// cannot tell an opening called `Intro` from ten minutes of story called the
/// same thing; here the other chapters and the running time settle it:
///
/// * a chapter that merely *reads* like an opening is one only when the file
/// has no chapter that says so outright, it runs the length of a theme song,
/// and it is near the front;
/// * a chapter that reads like credits is the ending when it runs to about that
/// length, or when it sits in the last quarter of the file.
///
/// Both rules fail *towards content*: not offering a skip costs a button, and
/// skipping the viewer out of the story costs them the story.
pub fn roles(chapters: &[Chapter], duration: Option<f64>) -> Vec<ChapterRole> {
let claims: Vec<Claim> = chapters
.iter()
.map(|chapter| Claim::of(chapter.title.as_deref().unwrap_or_default()))
.collect();
let has_real_opening = claims.contains(&Claim::Opening);
claims
.iter()
.enumerate()
.map(|(index, claim)| {
let length = chapter_end(chapters, index, duration)
.map(|end| end - chapters[index].start)
.filter(|length| *length > 0.0);
let start = chapters[index].start;
match claim {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
Claim::MaybeOpening
if !has_real_opening
&& start <= OPENING_BEFORE_SECONDS
&& length.is_some_and(|length| THEME_SECONDS.contains(&length)) =>
{
ChapterRole::Opening
}
Claim::MaybeEnding
if length.is_some_and(|length| CREDITS_SECONDS.contains(&length))
|| duration.is_some_and(|total| start >= total * TAIL_FRACTION) =>
{
ChapterRole::Ending
}
_ => ChapterRole::Content,
}
})
.collect()
}
/// Where the run of chapters that ends the file — credits, a preview, and
/// nothing the viewer would miss — begins.
///
/// **The suffix, not the last chapter.** A release that puts a Part C after the
/// ending and a preview after that (Fullmetal Alchemist Brotherhood does,
/// exactly once) must give up only the preview, or auto-advancing skips a scene
/// that matters. Walking back from the end and stopping at the first chapter
/// that is content is what expresses that: `… ED · Part C · Preview` yields the
/// preview alone, while the ordinary `… ED · Preview` yields both.
///
/// `None` when the file ends on content, has no chapters, or is *entirely*
/// skippable — the last of which is a mis-tagged file, not an episode that is
/// all credits.
pub fn credits_start(chapters: &[Chapter], roles: &[ChapterRole]) -> Option<f64> {
let first = roles
.iter()
.rposition(|role| !role.ends_the_episode())
.map(|last_content| last_content + 1)?;
if first >= chapters.len() {
return None;
}
Some(chapters[first].start)
}
/// Where the chapter starting at `index` ends: the next one's start, or the end
/// of the file.
pub fn chapter_end(chapters: &[Chapter], index: usize, duration: Option<f64>) -> Option<f64> {
chapters
.get(index + 1)
.map(|next| next.start)
.or(duration)
.filter(|end| *end > 0.0)
}
/// The chapter a position falls in.
///
/// Linear rather than a binary search: a file has a dozen chapters, and this is
/// called once a frame at most.
pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
chapters
.iter()
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
}
#[cfg(test)]
mod tests {
use super::*;
fn chapter(index: i64, title: &str, start: f64) -> Chapter {
Chapter {
index,
title: Some(title.to_string()),
start,
}
}
#[test]
fn the_names_releases_actually_use_are_recognised() {
for (name, claim) in [
("OP", Claim::Opening),
("Opening", Claim::Opening),
("OP1 - Again", Claim::Opening),
("02 - Opening", Claim::Opening),
("Opening Song", Claim::Opening),
("NCOP", Claim::Opening),
("オープニング", Claim::Opening),
("ED", Claim::Ending),
("Ending 2", Claim::Ending),
("End Credits", Claim::Ending),
("Cast Roll", Claim::Ending),
("Endcard", Claim::Ending),
("エンディング", Claim::Ending),
("Preview", Claim::Preview),
("Next Episode", Claim::Preview),
("Next Episode Preview", Claim::Preview),
("予告", Claim::Preview),
] {
assert_eq!(Claim::of(name), claim, "{name}");
}
}
#[test]
fn a_chapter_that_merely_starts_with_those_letters_is_not_one() {
// The reason this matches words and not prefixes: skipping the viewer
// out of "Operation Briefing" would be a bug they cannot undo without
// finding the seek bar.
for name in ["Operation Briefing", "Editor's cut", "Part A", "Part C", ""] {
assert_eq!(Claim::of(name), Claim::Content, "{name}");
}
}
/// The names that cannot be settled by reading them.
#[test]
fn an_ambiguous_name_is_not_decided_by_itself() {
assert_eq!(Claim::of("Intro"), Claim::MaybeOpening);
assert_eq!(Claim::of("Credits"), Claim::MaybeEnding);
assert_eq!(Claim::of("Cast"), Claim::MaybeEnding);
// And a chapter read in isolation never resolves to a skip.
assert_eq!(chapter(0, "Intro", 0.0).role(), ChapterRole::Content);
}
/// **The first episode of Oshi no Ko.** No opening, no ending — an eighty
/// minute episode that begins with a long `Intro` that is the story, and
/// ends with a cast scroll. Offering "skip opening" over the first ninety
/// seconds of *that* skips the beginning of the series.
#[test]
fn a_long_intro_is_the_episode_and_not_an_opening() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "Part A", 700.0),
chapter(2, "Part B", 2400.0),
chapter(3, "Cast", 4680.0),
];
let roles = roles(&chapters, Some(4800.0));
assert_eq!(
roles[0],
ChapterRole::Content,
"eleven minutes is not a theme"
);
assert_eq!(
roles[3],
ChapterRole::Ending,
"a cast roll at the end is one"
);
// Only the cast scroll is the tail; the episode is not skipped into.
assert_eq!(credits_start(&chapters, &roles), Some(4680.0));
}
/// The same name in the release that uses it for the theme song: ninety
/// seconds, near the front, and no other opening in the file.
#[test]
fn a_theme_length_intro_near_the_front_is_an_opening() {
let chapters = vec![
chapter(0, "Avant", 0.0),
chapter(1, "Intro", 90.0),
chapter(2, "Part A", 180.0),
];
assert_eq!(roles(&chapters, Some(1440.0))[1], ChapterRole::Opening);
// …but not when the file already says which chapter the opening is.
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 90.0),
chapter(2, "Part A", 180.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[0], ChapterRole::Content);
assert_eq!(roles[1], ChapterRole::Opening);
}
/// The ordinary shape: the credits and the trailer for next week are one
/// run at the end, and the countdown starts where the credits do.
#[test]
fn the_tail_is_the_whole_run_of_credits_and_preview() {
let chapters = vec![
chapter(0, "OP", 0.0),
chapter(1, "Part A", 90.0),
chapter(2, "Part B", 700.0),
chapter(3, "ED", 1320.0),
chapter(4, "Preview", 1410.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), Some(1320.0));
}
/// **Episode 46 of Fullmetal Alchemist Brotherhood.** A Part C *after* the
/// ending, and the preview after that. Only the preview may be skipped —
/// treating everything from the ending on as tail would drop a scene.
#[test]
fn content_after_the_ending_keeps_the_ending_out_of_the_tail() {
let chapters = vec![
chapter(0, "Part A", 0.0),
chapter(1, "Part B", 700.0),
chapter(2, "ED", 1260.0),
chapter(3, "Part C", 1350.0),
chapter(4, "Preview", 1400.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[2], ChapterRole::Ending, "it is still an ending");
assert_eq!(credits_start(&chapters, &roles), Some(1400.0));
}
/// A file that ends on the story has no tail at all, and one with no
/// chapters has nothing to read.
#[test]
fn a_file_that_ends_on_content_has_no_tail() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), None);
assert_eq!(credits_start(&[], &[]), None);
// Everything skippable is a mis-tagged file, not an episode that is
// entirely credits: auto-advancing out of it at second zero would be
// worse than doing nothing.
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "ED", 90.0)];
let roles = vec![ChapterRole::Ending, ChapterRole::Ending];
assert_eq!(credits_start(&chapters, &roles), None);
}
#[test]
fn a_position_lands_in_the_chapter_that_contains_it() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 24.0),
chapter(2, "Part A", 114.0),
];
assert_eq!(chapter_at(&chapters, 0.0), Some(0));
assert_eq!(chapter_at(&chapters, 23.9), Some(0));
// Exactly on a boundary belongs to the chapter that starts there,
// which is what makes "skip" land *out* of the opening rather than at
// its last frame.
assert_eq!(chapter_at(&chapters, 24.0), Some(1));
assert_eq!(chapter_at(&chapters, 3000.0), Some(2));
}
#[test]
fn a_chapter_before_the_first_one_starts_is_in_no_chapter() {
let chapters = vec![chapter(0, "OP", 24.0)];
assert_eq!(chapter_at(&chapters, 0.0), None);
}
#[test]
fn the_last_chapter_ends_where_the_file_does() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
assert_eq!(chapter_end(&chapters, 0, Some(1440.0)), Some(90.0));
assert_eq!(chapter_end(&chapters, 1, Some(1440.0)), Some(1440.0));
// No duration yet and nothing after it: there is nowhere to skip to,
// and a button that seeks to zero would be worse than no button.
assert_eq!(chapter_end(&chapters, 1, None), None);
}
}
+1
View File
@@ -7,6 +7,7 @@
pub mod appearance;
pub mod catalog;
pub mod chapters;
pub mod config;
pub mod error;
pub mod library;
+25
View File
@@ -18,6 +18,11 @@ use crate::error::Result;
/// referenced here because `pstr-core` does not depend on the player.
const MAX_VOLUME: f64 = 100.0;
/// The range of playback rates offered. Past these, audio stops being speech
/// and video stops being watchable, and mpv's own resampler gives up first.
const MIN_SPEED: f64 = 0.25;
const MAX_SPEED: f64 = 4.0;
/// How playback should sound, and in which language.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
// A file written by an older version, or hand-edited, is missing fields rather
@@ -43,6 +48,18 @@ pub struct PlaybackPrefs {
/// does and what a season of anything is watched like. Off is one checkbox
/// away for someone who falls asleep to it.
pub autoplay_next: bool,
/// Whether an opening or ending is seeked past rather than offered.
///
/// Off by default, and deliberately: chapter names are a guess about a file
/// somebody else muxed, and a wrong guess that only greys out a button
/// costs nothing while a wrong guess that seeks costs the scene.
pub auto_skip: bool,
/// How fast playback runs, 1.0 being the file's own rate.
///
/// Kept across episodes deliberately: someone who watches at 1.25 watches
/// everything at 1.25, and a speed that reset every episode would be a
/// setting nobody could use.
pub speed: f64,
}
impl Default for PlaybackPrefs {
@@ -54,6 +71,8 @@ impl Default for PlaybackPrefs {
subtitle_language: None,
subtitles: true,
autoplay_next: true,
auto_skip: false,
speed: 1.0,
}
}
}
@@ -68,6 +87,10 @@ impl PlaybackPrefs {
self.volume = MAX_VOLUME;
}
self.volume = self.volume.clamp(0.0, MAX_VOLUME);
if !self.speed.is_finite() {
self.speed = 1.0;
}
self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED);
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
self
@@ -124,6 +147,8 @@ mod tests {
subtitle_language: Some("eng".into()),
subtitles: true,
autoplay_next: false,
auto_skip: true,
speed: 1.5,
};
save(&dirs, &prefs).unwrap();
assert_eq!(load(&dirs).unwrap(), prefs);
+177
View File
@@ -160,6 +160,49 @@ impl ShareStore {
Ok(share)
}
/// Re-supply a configured share's secrets, keeping everything else.
///
/// A secret store can lose the ability to read what it wrote: an Android
/// Keystore key is invalidated by a lockscreen change or a device restore,
/// and a desktop keyring can be reset. After that every open of the share
/// fails — and so, often, does removing it, because removal deletes a
/// secret the store can no longer touch. Re-entering the link is the way
/// back that does not cost the catalog and the offline files.
///
/// The link must be the same one: a different token is a different share,
/// and quietly repointing an existing library at it would leave every
/// catalog row describing files that are no longer there.
pub fn replace_secrets(
&self,
id: &str,
url: &str,
custom_password: Option<&str>,
) -> Result<Share> {
let token = token_from_url(url)?;
let mut shares = self.list()?;
let share = shares
.iter_mut()
.find(|share| share.id == id)
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
if share.token != token {
return Err(Error::Config(
"that link is for a different share; add it as a new one".to_owned(),
));
}
let secrets = ShareSecrets {
url: url.to_string(),
custom_password: custom_password
.filter(|password| !password.is_empty())
.map(str::to_string),
};
self.store_secrets(id, &secrets)?;
share.has_custom_password = secrets.custom_password.is_some();
let updated = share.clone();
write_json(&self.dirs.shares_file(), &shares)?;
Ok(updated)
}
/// Forget a share and its secrets.
///
/// The config entry goes first here, for the mirror-image reason: if the
@@ -223,10 +266,28 @@ impl SharedLibrary {
/// the whole library — one revoked link should not make the other three
/// unwatchable.
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> {
Self::open_all_reusing(store, BTreeMap::new()).await
}
/// Open every configured share, reusing the clients in `reusable`.
///
/// Adding or removing one link should not cost a fresh handshake for the
/// shares that were already open, so a caller that still holds them can
/// hand them back here. A share whose stored secrets changed must *not*
/// appear in `reusable` — its old client authenticates against the old
/// link.
pub async fn open_all_reusing(
store: &ShareStore,
mut reusable: BTreeMap<String, ProtonDrivePublicLinkClient>,
) -> Result<(Self, Vec<(Share, Error)>)> {
let mut clients = BTreeMap::new();
let mut failures = Vec::new();
for share in store.list()? {
if let Some(client) = reusable.remove(&share.id) {
clients.insert(share.id, client);
continue;
}
match store.open(&share).await {
Ok(client) => {
clients.insert(share.id.clone(), client);
@@ -238,6 +299,23 @@ impl SharedLibrary {
Ok((Self { clients }, failures))
}
/// The opened clients, minus `dropping`, ready to hand to
/// [`SharedLibrary::open_all_reusing`].
///
/// The excluded share is the one whose credentials or membership just
/// changed; leaving it out is what makes a removed share's live client
/// unreachable rather than merely stale.
pub fn reusable_clients(
&self,
dropping: &str,
) -> BTreeMap<String, ProtonDrivePublicLinkClient> {
self.clients
.iter()
.filter(|(id, _)| id.as_str() != dropping)
.map(|(id, client)| (id.clone(), client.clone()))
.collect()
}
/// The client for one share.
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> {
self.clients.get(share_id)
@@ -396,6 +474,105 @@ mod tests {
}
}
/// A store that has forgotten how to read what it wrote, which is what an
/// invalidated Android Keystore key looks like from Rust.
#[derive(Default)]
struct Unreadable {
entries: std::sync::Mutex<BTreeMap<String, String>>,
readable: std::sync::atomic::AtomicBool,
}
impl SecretStore for Unreadable {
fn set(&self, key: &str, value: &str) -> Result<()> {
self.entries
.lock()
.unwrap()
.insert(key.to_owned(), value.to_owned());
self.readable
.store(true, std::sync::atomic::Ordering::Relaxed);
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
if !self.readable.load(std::sync::atomic::Ordering::Relaxed) {
return Err(Error::Config("key permanently invalidated".into()));
}
Ok(self.entries.lock().unwrap().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.entries.lock().unwrap().remove(key);
Ok(())
}
}
fn store_in(name: &str) -> (ShareStore, Arc<Unreadable>) {
let root = std::env::temp_dir().join(format!("pstr-shares-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dirs = AppDirs::from_paths(root.join("config"), root.join("data"), root.join("cache"))
.expect("app directories");
let secrets = Arc::new(Unreadable::default());
(
ShareStore::with_secret_store(dirs, secrets.clone()),
secrets,
)
}
/// The way back from an unreadable secret store. The share keeps its id, so
/// the catalog rows and offline files keyed by it stay valid — which is the
/// whole reason this exists rather than Remove-and-re-add.
#[test]
fn a_re_entered_link_restores_a_share_whose_secret_cannot_be_read() {
let (store, secrets) = store_in("repair");
let url = "https://drive.proton.me/urls/ABC123#s3cr3t";
let added = store.add("Library", url, None).expect("add");
secrets
.readable
.store(false, std::sync::atomic::Ordering::Relaxed);
assert!(store.load_secrets(&added.id).is_err());
let repaired = store
.replace_secrets(&added.id, url, Some("new-password"))
.expect("replace");
assert_eq!(repaired.id, added.id);
assert!(repaired.has_custom_password);
assert!(
store
.list()
.expect("list")
.iter()
.any(|share| share.id == added.id && share.has_custom_password)
);
}
/// A different link is a different share. Repointing this one at it would
/// leave every catalog row and offline file describing files that are not
/// there.
#[test]
fn a_re_entered_link_for_a_different_share_is_refused() {
let (store, _) = store_in("repair-mismatch");
let added = store
.add(
"Library",
"https://drive.proton.me/urls/ABC123#s3cr3t",
None,
)
.expect("add");
let error = store
.replace_secrets(
&added.id,
"https://drive.proton.me/urls/DEF456#s3cr3t",
None,
)
.expect_err("a different token must be refused");
assert!(
error.to_string().contains("different share"),
"says what is wrong: {error}"
);
}
/// The id must be derivable from the token alone — deriving it from the URL
/// would mix a secret into the config file.
#[test]
+48
View File
@@ -223,6 +223,54 @@ impl MetadataService {
Ok(episodes)
}
/// The episode list for a whole title, with a failure treated as an empty
/// one.
///
/// Deliberately not fatal to the title it belongs to: a poster and a
/// synopsis that arrived are worth keeping even when the episode request
/// was the one that hit the rate limit. The empty result is not cached as
/// an answer — the next match run asks again — because the only thing that
/// triggers one is a viewer pressing the button.
///
/// The match itself only ever answers for *one* entry, and on a provider
/// that files each sequel separately — AniList — that entry is season one.
/// So each further season of the title is searched for by name and its
/// episodes are tagged with the season they came from; without that,
/// seasons two and three have no episode names at all. See
/// [`MetadataService::season_episodes`].
pub async fn title_episodes(
&self,
title: &Title,
found: &TitleMetadata,
) -> Vec<EpisodeMetadata> {
let mut episodes = self
.episodes(found)
.await
.inspect_err(|error| tracing::warn!("episodes for {}: {error}", found.name))
.unwrap_or_default();
if !self.splits_seasons() {
return episodes;
}
// Season one is what the title's own match already answered for.
let later: Vec<u32> = title
.seasons
.iter()
.filter_map(|season| season.number)
.filter(|number| *number > 1)
.collect();
for season in later {
match self.season_episodes(title, season).await {
Ok(found) => episodes.extend(found),
Err(error) => {
tracing::warn!("episodes for {} season {season}: {error}", title.name);
}
}
}
episodes
}
/// Whether this provider files a sequel as its own entry, so that a title
/// with several seasons has to be searched for once per season.
pub fn splits_seasons(&self) -> bool {
+3
View File
@@ -9,6 +9,9 @@ rust-version.workspace = true
authors.workspace = true
[dependencies]
# For `chapters`, which Android reaches through its own bridge and so cannot
# live here. Already in the tree via pstr-stream.
pstr-core.workspace = true
pstr-stream.workspace = true
tokio.workspace = true
thiserror.workspace = true
+9 -478
View File
@@ -1,280 +1,19 @@
//! The chapters a file was muxed with, and what they are for.
//! Reading the chapter list out of the player.
//!
//! The chapters themselves — what a name claims, what the rest of the file
//! settles, where the tail begins — live in [`pstr_core::chapters`], because
//! Android reads its chapter list through its own JNI adapter and must reach
//! the same verdict as this crate does. Only the mpv read is here.
//!
//! Read the same way as the track list — `chapter-list/N/title`, one
//! sub-property at a time — for the same reason: those are plain strings and
//! doubles, and a node walk would buy nothing.
//!
//! Anime releases mark their opening and ending as chapters, which is the whole
//! reason this exists: with them a player can offer to skip the ninety seconds
//! everyone skips, and without them it cannot.
use libmpv2::Mpv;
/// One chapter of the file.
#[derive(Debug, Clone, PartialEq)]
pub struct Chapter {
/// Position in the list, which is what mpv's `chapter` property is set to.
pub index: i64,
/// The name the muxer gave it, if any.
pub title: Option<String>,
/// Where it starts, in seconds.
pub start: f64,
}
impl Chapter {
/// What to show in a list. A nameless chapter is still worth an entry —
/// it is a place in the file — so it gets its number.
pub fn label(&self) -> String {
match &self.title {
Some(title) => title.clone(),
None => format!("Chapter {}", self.index + 1),
}
}
/// What this chapter is, from its name alone.
///
/// Prefer [`roles`], which resolves the names that only mean one thing *in
/// context* — a chapter called `Intro` is an opening in one release and the
/// first ten minutes of the story in another.
pub fn role(&self) -> ChapterRole {
match Claim::of(self.title.as_deref().unwrap_or_default()) {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
// Unresolved without the rest of the file: assume it is content,
// which is the reading that never skips anything.
_ => ChapterRole::Content,
}
}
}
/// The kinds of chapter worth treating differently from "part of the episode".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChapterRole {
/// The opening titles.
Opening,
/// The ending titles.
Ending,
/// A preview of the next episode, after the ending.
Preview,
/// Anything else, including everything unnamed.
Content,
}
impl ChapterRole {
/// What a button offering to skip this should say. `None` for a chapter
/// nobody wants skipped.
pub fn skip_label(self) -> Option<&'static str> {
match self {
Self::Opening => Some("Skip opening"),
Self::Ending => Some("Skip ending"),
Self::Preview => Some("Skip preview"),
Self::Content => None,
}
}
/// Whether this is something the file could end on without the viewer
/// missing anything: credits and a trailer for next week.
///
/// An *opening* is not, even though it is skippable — a file does not end
/// with one, and treating it as tail would make a mis-tagged chapter
/// auto-advance out of the episode.
pub fn ends_the_episode(self) -> bool {
matches!(self, Self::Ending | Self::Preview)
}
}
/// What a chapter's *name* claims, before the rest of the file is consulted.
///
/// The two `Maybe` variants are the whole reason this is separate from
/// [`ChapterRole`]. `Intro` is the opening in one release and, in the first
/// episode of Oshi no Ko, a ten-minute introduction to the series that a viewer
/// must not be skipped out of. `Credits` and `Cast` are the ending in most
/// files and a mid-episode scene in a few. Neither can be settled by the name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Claim {
Opening,
Ending,
Preview,
/// Reads like an opening, but only if the file has no real one and it is
/// the length of a theme song.
MaybeOpening,
/// Reads like credits, but only near the end of the file or at theme
/// length.
MaybeEnding,
Content,
}
/// How long an opening or ending theme runs.
///
/// Anime themes are ninety seconds by convention and rarely stray far. This is
/// the corroboration for a name that could go either way: a chapter called
/// `Intro` that runs eleven minutes is not a theme song, whatever it is called.
const THEME_SECONDS: std::ops::RangeInclusive<f64> = 45.0..=150.0;
/// Credits can run longer than a theme — a full cast roll over a still — so a
/// chapter that only *reads* like an ending gets this much room when it sits at
/// the end of the file.
const CREDITS_SECONDS: std::ops::RangeInclusive<f64> = 30.0..=300.0;
/// An opening that far into the file is not an opening. Cold opens run long in
/// a first episode, so this is generous.
const OPENING_BEFORE_SECONDS: f64 = 480.0;
/// Where the last part of a file starts, as a fraction of it. A chapter that
/// merely reads like credits is taken as credits past this point.
const TAIL_FRACTION: f64 = 0.75;
impl Claim {
/// Read a claim out of a chapter name.
///
/// Matched on whole words from the front, not on substrings: `Operation
/// Briefing` starts with `op` and is not an opening, `Ending Note` is not
/// an ending, and skipping a viewer out of either is a mistake they cannot
/// undo without finding the seek bar. Leading numbering (`01 - OP`, `1.
/// Opening`) is stepped over first, because plenty of muxes carry it.
fn of(title: &str) -> Self {
// Japanese names appear verbatim in releases from JP sources, and they
// are unambiguous where the English ones are not. Checked before the
// word split, which only knows about ASCII and would find no words at
// all in a name written entirely in kana.
if title.contains("オープニング") {
return Self::Opening;
}
if title.contains("エンディング") {
return Self::Ending;
}
if title.contains("予告") {
return Self::Preview;
}
let words = words(title);
let words: Vec<&str> = words.iter().map(String::as_str).collect();
let Some((head, rest)) = words.split_first() else {
return Self::Content;
};
let second = rest.first().copied().unwrap_or_default();
match (*head, second) {
// Two-word forms first: the second word is what tells `next
// episode` from `next`, and `end credits` from `end`.
("next", _) | ("nep", _) => Self::Preview,
("end" | "ending" | "closing", "credits") => Self::Ending,
("cast" | "staff", "roll" | "credits") => Self::Ending,
("opening" | "op", "theme" | "song" | "credits") => Self::Opening,
("ending" | "ed", "theme" | "song") => Self::Ending,
("op" | "opening" | "openning" | "ncop", _) => Self::Opening,
("ed" | "ending" | "nced" | "outro" | "endcard", _) => Self::Ending,
("preview" | "pv" | "trailer" | "teaser" | "yokoku", _) => Self::Preview,
("intro", _) => Self::MaybeOpening,
("credits" | "closing" | "cast" | "staff", _) => Self::MaybeEnding,
_ => Self::Content,
}
}
}
/// A chapter name as lowercase words, with any leading numbering dropped.
///
/// `OP1` and `ED2` keep their tag and lose their number — the digit is part of
/// the tag, not a word — while `02 - Opening` loses its leading `02` entirely.
fn words(title: &str) -> Vec<String> {
let mut words: Vec<String> = title
.trim()
.to_ascii_lowercase()
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|word| !word.is_empty())
.map(|word| {
word.trim_end_matches(|c: char| c.is_ascii_digit())
.to_string()
})
.collect();
// A leading token that was *only* digits is numbering, not a name.
if words.first().is_some_and(String::is_empty) {
words.remove(0);
}
words.retain(|word| !word.is_empty());
words
}
/// The role of every chapter in a file, with the file's own shape taken into
/// account.
///
/// This is the function to use. [`Chapter::role`] reads a name in isolation and
/// cannot tell an opening called `Intro` from ten minutes of story called the
/// same thing; here the other chapters and the running time settle it:
///
/// * a chapter that merely *reads* like an opening is one only when the file
/// has no chapter that says so outright, it runs the length of a theme song,
/// and it is near the front;
/// * a chapter that reads like credits is the ending when it runs to about that
/// length, or when it sits in the last quarter of the file.
///
/// Both rules fail *towards content*: not offering a skip costs a button, and
/// skipping the viewer out of the story costs them the story.
pub fn roles(chapters: &[Chapter], duration: Option<f64>) -> Vec<ChapterRole> {
let claims: Vec<Claim> = chapters
.iter()
.map(|chapter| Claim::of(chapter.title.as_deref().unwrap_or_default()))
.collect();
let has_real_opening = claims.contains(&Claim::Opening);
claims
.iter()
.enumerate()
.map(|(index, claim)| {
let length = chapter_end(chapters, index, duration)
.map(|end| end - chapters[index].start)
.filter(|length| *length > 0.0);
let start = chapters[index].start;
match claim {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
Claim::MaybeOpening
if !has_real_opening
&& start <= OPENING_BEFORE_SECONDS
&& length.is_some_and(|length| THEME_SECONDS.contains(&length)) =>
{
ChapterRole::Opening
}
Claim::MaybeEnding
if length.is_some_and(|length| CREDITS_SECONDS.contains(&length))
|| duration.is_some_and(|total| start >= total * TAIL_FRACTION) =>
{
ChapterRole::Ending
}
_ => ChapterRole::Content,
}
})
.collect()
}
/// Where the run of chapters that ends the file — credits, a preview, and
/// nothing the viewer would miss — begins.
///
/// **The suffix, not the last chapter.** A release that puts a Part C after the
/// ending and a preview after that (Fullmetal Alchemist Brotherhood does,
/// exactly once) must give up only the preview, or auto-advancing skips a scene
/// that matters. Walking back from the end and stopping at the first chapter
/// that is content is what expresses that: `… ED · Part C · Preview` yields the
/// preview alone, while the ordinary `… ED · Preview` yields both.
///
/// `None` when the file ends on content, has no chapters, or is *entirely*
/// skippable — the last of which is a mis-tagged file, not an episode that is
/// all credits.
pub fn credits_start(chapters: &[Chapter], roles: &[ChapterRole]) -> Option<f64> {
let first = roles
.iter()
.rposition(|role| !role.ends_the_episode())
.map(|last_content| last_content + 1)?;
if first >= chapters.len() {
return None;
}
Some(chapters[first].start)
}
pub use pstr_core::chapters::{
Chapter, ChapterRole, chapter_at, chapter_end, credits_start, roles,
};
/// Every chapter of the file mpv currently has open.
pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
@@ -293,211 +32,3 @@ pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
})
.collect()
}
/// Where the chapter starting at `index` ends: the next one's start, or the end
/// of the file.
pub fn chapter_end(chapters: &[Chapter], index: usize, duration: Option<f64>) -> Option<f64> {
chapters
.get(index + 1)
.map(|next| next.start)
.or(duration)
.filter(|end| *end > 0.0)
}
/// The chapter a position falls in.
///
/// Linear rather than a binary search: a file has a dozen chapters, and this is
/// called once a frame at most.
pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
chapters
.iter()
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
}
#[cfg(test)]
mod tests {
use super::*;
fn chapter(index: i64, title: &str, start: f64) -> Chapter {
Chapter {
index,
title: Some(title.to_string()),
start,
}
}
#[test]
fn the_names_releases_actually_use_are_recognised() {
for (name, claim) in [
("OP", Claim::Opening),
("Opening", Claim::Opening),
("OP1 - Again", Claim::Opening),
("02 - Opening", Claim::Opening),
("Opening Song", Claim::Opening),
("NCOP", Claim::Opening),
("オープニング", Claim::Opening),
("ED", Claim::Ending),
("Ending 2", Claim::Ending),
("End Credits", Claim::Ending),
("Cast Roll", Claim::Ending),
("Endcard", Claim::Ending),
("エンディング", Claim::Ending),
("Preview", Claim::Preview),
("Next Episode", Claim::Preview),
("Next Episode Preview", Claim::Preview),
("予告", Claim::Preview),
] {
assert_eq!(Claim::of(name), claim, "{name}");
}
}
#[test]
fn a_chapter_that_merely_starts_with_those_letters_is_not_one() {
// The reason this matches words and not prefixes: skipping the viewer
// out of "Operation Briefing" would be a bug they cannot undo without
// finding the seek bar.
for name in ["Operation Briefing", "Editor's cut", "Part A", "Part C", ""] {
assert_eq!(Claim::of(name), Claim::Content, "{name}");
}
}
/// The names that cannot be settled by reading them.
#[test]
fn an_ambiguous_name_is_not_decided_by_itself() {
assert_eq!(Claim::of("Intro"), Claim::MaybeOpening);
assert_eq!(Claim::of("Credits"), Claim::MaybeEnding);
assert_eq!(Claim::of("Cast"), Claim::MaybeEnding);
// And a chapter read in isolation never resolves to a skip.
assert_eq!(chapter(0, "Intro", 0.0).role(), ChapterRole::Content);
}
/// **The first episode of Oshi no Ko.** No opening, no ending — an eighty
/// minute episode that begins with a long `Intro` that is the story, and
/// ends with a cast scroll. Offering "skip opening" over the first ninety
/// seconds of *that* skips the beginning of the series.
#[test]
fn a_long_intro_is_the_episode_and_not_an_opening() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "Part A", 700.0),
chapter(2, "Part B", 2400.0),
chapter(3, "Cast", 4680.0),
];
let roles = roles(&chapters, Some(4800.0));
assert_eq!(
roles[0],
ChapterRole::Content,
"eleven minutes is not a theme"
);
assert_eq!(
roles[3],
ChapterRole::Ending,
"a cast roll at the end is one"
);
// Only the cast scroll is the tail; the episode is not skipped into.
assert_eq!(credits_start(&chapters, &roles), Some(4680.0));
}
/// The same name in the release that uses it for the theme song: ninety
/// seconds, near the front, and no other opening in the file.
#[test]
fn a_theme_length_intro_near_the_front_is_an_opening() {
let chapters = vec![
chapter(0, "Avant", 0.0),
chapter(1, "Intro", 90.0),
chapter(2, "Part A", 180.0),
];
assert_eq!(roles(&chapters, Some(1440.0))[1], ChapterRole::Opening);
// …but not when the file already says which chapter the opening is.
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 90.0),
chapter(2, "Part A", 180.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[0], ChapterRole::Content);
assert_eq!(roles[1], ChapterRole::Opening);
}
/// The ordinary shape: the credits and the trailer for next week are one
/// run at the end, and the countdown starts where the credits do.
#[test]
fn the_tail_is_the_whole_run_of_credits_and_preview() {
let chapters = vec![
chapter(0, "OP", 0.0),
chapter(1, "Part A", 90.0),
chapter(2, "Part B", 700.0),
chapter(3, "ED", 1320.0),
chapter(4, "Preview", 1410.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), Some(1320.0));
}
/// **Episode 46 of Fullmetal Alchemist Brotherhood.** A Part C *after* the
/// ending, and the preview after that. Only the preview may be skipped —
/// treating everything from the ending on as tail would drop a scene.
#[test]
fn content_after_the_ending_keeps_the_ending_out_of_the_tail() {
let chapters = vec![
chapter(0, "Part A", 0.0),
chapter(1, "Part B", 700.0),
chapter(2, "ED", 1260.0),
chapter(3, "Part C", 1350.0),
chapter(4, "Preview", 1400.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[2], ChapterRole::Ending, "it is still an ending");
assert_eq!(credits_start(&chapters, &roles), Some(1400.0));
}
/// A file that ends on the story has no tail at all, and one with no
/// chapters has nothing to read.
#[test]
fn a_file_that_ends_on_content_has_no_tail() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), None);
assert_eq!(credits_start(&[], &[]), None);
// Everything skippable is a mis-tagged file, not an episode that is
// entirely credits: auto-advancing out of it at second zero would be
// worse than doing nothing.
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "ED", 90.0)];
let roles = vec![ChapterRole::Ending, ChapterRole::Ending];
assert_eq!(credits_start(&chapters, &roles), None);
}
#[test]
fn a_position_lands_in_the_chapter_that_contains_it() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 24.0),
chapter(2, "Part A", 114.0),
];
assert_eq!(chapter_at(&chapters, 0.0), Some(0));
assert_eq!(chapter_at(&chapters, 23.9), Some(0));
// Exactly on a boundary belongs to the chapter that starts there,
// which is what makes "skip" land *out* of the opening rather than at
// its last frame.
assert_eq!(chapter_at(&chapters, 24.0), Some(1));
assert_eq!(chapter_at(&chapters, 3000.0), Some(2));
}
#[test]
fn a_chapter_before_the_first_one_starts_is_in_no_chapter() {
let chapters = vec![chapter(0, "OP", 24.0)];
assert_eq!(chapter_at(&chapters, 0.0), None);
}
#[test]
fn the_last_chapter_ends_where_the_file_does() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
assert_eq!(chapter_end(&chapters, 0, Some(1440.0)), Some(90.0));
assert_eq!(chapter_end(&chapters, 1, Some(1440.0)), Some(1440.0));
// No duration yet and nothing after it: there is nowhere to skip to,
// and a button that seeks to zero would be worse than no button.
assert_eq!(chapter_end(&chapters, 1, None), None);
}
}
+4
View File
@@ -165,6 +165,10 @@ impl StreamSource {
uid: uid.clone(),
};
if let Some(stream) = self.inner.lock().pop(&key) {
// Before the ring is cleared, not after: a prefetch still in flight
// would otherwise re-insert into the ring this just emptied, and
// keep downloading for a stream nobody is reading.
stream.cancel_read_ahead();
self.inner.ring.forget(uid, stream.revision_id());
}
}
+71 -16
View File
@@ -259,20 +259,12 @@ impl VideoStream {
/// wants an aborted block simply refetches it — the single-flight entry is
/// released by its guard when the task is dropped, so nothing waits on a
/// call that will never finish.
fn cancel_read_ahead(&self) {
let tasks = std::mem::take(&mut *self.inner.tasks());
let mut cancelled = 0_u64;
for task in tasks {
if !task.is_finished() {
task.abort();
cancelled += 1;
}
}
if cancelled > 0 {
self.inner
.readahead_cancelled
.fetch_add(cancelled, Ordering::Relaxed);
}
///
/// Public because a seek is not the only reason to stop speculating: a
/// stream nobody is reading any more should not keep spending a phone's
/// data plan, which is what [`crate::StreamSource::close`] uses this for.
pub fn cancel_read_ahead(&self) {
self.inner.cancel_read_ahead();
}
/// Queue the next few blocks after `from`.
@@ -300,10 +292,15 @@ impl VideoStream {
};
inner.readahead_started.fetch_add(1, Ordering::Relaxed);
let spawned = Arc::clone(inner);
// Weak, so that a queue of prefetches cannot keep the stream they
// are speculating for alive — see `impl Drop for Inner`.
let spawned = Arc::downgrade(inner);
let task = tokio::spawn(async move {
let _slot = slot;
if let Err(e) = block_at(spawned, index).await {
let Some(inner) = spawned.upgrade() else {
return;
};
if let Err(e) = block_at(inner, index).await {
tracing::debug!(index, error = %e, "read-ahead block failed");
}
});
@@ -352,6 +349,39 @@ impl Inner {
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
fn cancel_read_ahead(&self) {
let tasks = std::mem::take(&mut *self.tasks());
let mut cancelled = 0_u64;
for task in tasks {
if !task.is_finished() {
task.abort();
cancelled += 1;
}
}
if cancelled > 0 {
self.readahead_cancelled
.fetch_add(cancelled, Ordering::Relaxed);
}
}
}
/// A stream nobody holds any more stops speculating.
///
/// Read-ahead tasks hold a [`Weak`] rather than an [`Arc`], so their existence
/// does not keep this alive and this can run while they are still in flight —
/// which is the point. Without it, closing the player mid-episode leaves up to a
/// full read-ahead window downloading for a stream nobody is watching, and those
/// blocks then evict the ones the *next* stream has already fetched.
///
/// A prefetch that has already upgraded its handle keeps this alive for the one
/// block it is fetching. That block is the residue; the window behind it is not.
impl Drop for Inner {
fn drop(&mut self) {
for task in std::mem::take(&mut *self.tasks()) {
task.abort();
}
}
}
/// Resolve one block: memory, then disk, then the network.
@@ -791,6 +821,31 @@ mod tests {
);
}
/// Closing the player mid-episode must stop the speculation too. On a phone
/// on cellular this is up to a full read-ahead window downloaded for a
/// stream nobody is watching — and those blocks then evict the ones the next
/// stream has already fetched.
#[tokio::test]
async fn abandoning_a_stream_stops_its_read_ahead() {
let fake = Fake::slow(&[1024 * 1024; 200], std::time::Duration::from_millis(200));
let stream = stream_over(Arc::clone(&fake), 4);
stream.read_range(0, 1_000).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert!(stream.stats().readahead_blocks > 0, "nothing to cancel");
let started = fake.fetches();
drop(stream);
// Long enough that every prefetch in flight would have completed.
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
assert!(
fake.fetches() <= started + 1,
"read-ahead outlived the stream: {} fetches after {started}",
fake.fetches(),
);
}
/// And the mirror image: steady playback must not keep throwing its own
/// read-ahead away.
#[tokio::test]