mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(player,core): remember the audio/subtitle track by name across episodes
A track picked on one episode now carries over to the next by its title as well as its language, so e.g. "Signs & Songs" vs "Full" subtitles in the same language stick. Catalog migration V8 adds the title columns; the Android bridge keeps a stored title only while its language is unchanged.
This commit is contained in:
1 parent
ad24557588
commit
d3c5f071fb
7 files changed
+308
-28
No files matched your search
@@ -1127,13 +1127,27 @@ impl AndroidEngine {
|
||||
title_key: String,
|
||||
preferences: TrackPreferencesRecord,
|
||||
) -> Result<(), BridgeError> {
|
||||
let catalog = self.catalog.lock();
|
||||
let existing = catalog
|
||||
.title_track_prefs(&title_key)
|
||||
.map_err(BridgeError::from_display)?
|
||||
.unwrap_or_default();
|
||||
let audio_language = normalized_language(preferences.audio_language);
|
||||
let subtitle_language = normalized_language(preferences.subtitle_language);
|
||||
// This bridge carries languages only. A track title stored beside one
|
||||
// stays for as long as the language it was picked under does.
|
||||
let preferences = TitleTrackPrefs {
|
||||
audio_language: normalized_language(preferences.audio_language),
|
||||
subtitle_language: normalized_language(preferences.subtitle_language),
|
||||
audio_title: existing
|
||||
.audio_title
|
||||
.filter(|_| existing.audio_language == audio_language),
|
||||
subtitle_title: existing
|
||||
.subtitle_title
|
||||
.filter(|_| existing.subtitle_language == subtitle_language),
|
||||
audio_language,
|
||||
subtitle_language,
|
||||
subtitles: preferences.subtitles,
|
||||
};
|
||||
self.catalog
|
||||
.lock()
|
||||
catalog
|
||||
.set_title_track_prefs(&title_key, &preferences)
|
||||
.map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
@@ -866,28 +866,35 @@ impl App {
|
||||
Action::SelectTrack { kind, id } => {
|
||||
self.send_player(crate::playback::Command::SelectTrack(kind, id));
|
||||
|
||||
// Remember the *language*, not the track number: the next
|
||||
// episode is a different file, where track 3 may be a
|
||||
// commentary and Japanese may be track 2.
|
||||
let language = id.and_then(|id| {
|
||||
// Remember the language and the track's name, not its number:
|
||||
// the next episode is a different file, where track 3 may be a
|
||||
// commentary and Japanese may be track 2. The name is what
|
||||
// tells "Signs & Songs" from "Full Subtitles" in one language.
|
||||
let (language, title) = id
|
||||
.and_then(|id| {
|
||||
self.playback.as_ref().and_then(|playback| {
|
||||
playback
|
||||
.tracks_of(kind)
|
||||
.find(|track| track.id == id)
|
||||
.and_then(|track| track.language.clone())
|
||||
.map(|track| (track.language.clone(), track.title.clone()))
|
||||
})
|
||||
});
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(playback) = &self.playback {
|
||||
let mut show = self
|
||||
.engine
|
||||
.title_track_prefs(&playback.target.title_key)
|
||||
.unwrap_or_default();
|
||||
match kind {
|
||||
pstr_player::TrackKind::Audio => show.audio_language = language,
|
||||
pstr_player::TrackKind::Audio => {
|
||||
show.audio_language = language;
|
||||
show.audio_title = title;
|
||||
}
|
||||
pstr_player::TrackKind::Subtitle => {
|
||||
show.subtitles = id.is_some();
|
||||
if id.is_some() {
|
||||
show.subtitle_language = language;
|
||||
show.subtitle_title = title;
|
||||
}
|
||||
}
|
||||
pstr_player::TrackKind::Video => {}
|
||||
|
||||
@@ -487,8 +487,12 @@ fn run(
|
||||
PlayerEvent::FileLoaded => {
|
||||
duration = player.duration();
|
||||
// The first moment there is a track list to read: mpv has
|
||||
// demuxed the file and made its own default selection, which
|
||||
// is what the menus have to open showing.
|
||||
// demuxed the file and made its own default selection. Put the
|
||||
// title's own choice over it before the menus are told, so they
|
||||
// open showing what is actually playing.
|
||||
if let Some(show) = target.track_prefs.as_deref() {
|
||||
restore_tracks(&player, show);
|
||||
}
|
||||
emit_tracks(&engine, id, &player);
|
||||
// Only now is there a timeline to seek within.
|
||||
if !resumed && let Some(at) = target.resume_at {
|
||||
@@ -611,6 +615,44 @@ fn select_track(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put back the tracks this title was last watched with.
|
||||
///
|
||||
/// `alang`/`slang` already steered mpv's pick by language. This is for what a
|
||||
/// language cannot say — which of two English subtitle tracks, or an audio track
|
||||
/// with a name and no tag — so only a remembered *title* acts here; a title with
|
||||
/// nothing but a language keeps mpv's selection, which honours it already.
|
||||
fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) {
|
||||
let tracks = player.tracks();
|
||||
let wanted = [
|
||||
(
|
||||
TrackKind::Audio,
|
||||
show.audio_language.as_deref(),
|
||||
show.audio_title.as_deref(),
|
||||
true,
|
||||
),
|
||||
(
|
||||
TrackKind::Subtitle,
|
||||
show.subtitle_language.as_deref(),
|
||||
show.subtitle_title.as_deref(),
|
||||
show.subtitles,
|
||||
),
|
||||
];
|
||||
for (kind, language, title, enabled) in wanted {
|
||||
if !enabled || title.is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(track) = pstr_player::pick_track(&tracks, kind, language, title) else {
|
||||
continue;
|
||||
};
|
||||
let playing = tracks
|
||||
.iter()
|
||||
.any(|candidate| candidate.kind == kind && candidate.id == track && candidate.selected);
|
||||
if !playing && let Err(error) = player.select_track(kind, Some(track)) {
|
||||
tracing::warn!("restore {} track {track}: {error}", kind.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the UI what the file contains and what of it is playing.
|
||||
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
||||
engine.emit(Event::Player {
|
||||
|
||||
@@ -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 = 7;
|
||||
pub const SCHEMA_VERSION: i64 = 8;
|
||||
|
||||
const MIGRATION_V1: &str = r#"
|
||||
CREATE TABLE nodes (
|
||||
@@ -146,6 +146,13 @@ CREATE TABLE title_track_prefs (
|
||||
);
|
||||
"#;
|
||||
|
||||
/// A language cannot tell two English subtitle tracks apart — "Signs & Songs"
|
||||
/// and "Full Subtitles" — so the track's title is remembered beside it.
|
||||
const MIGRATION_V8: &str = r#"
|
||||
ALTER TABLE title_track_prefs ADD COLUMN audio_title TEXT;
|
||||
ALTER TABLE title_track_prefs ADD COLUMN subtitle_title TEXT;
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OfflineFile {
|
||||
pub revision_id: String,
|
||||
@@ -157,6 +164,12 @@ pub struct TitleTrackPrefs {
|
||||
pub audio_language: Option<String>,
|
||||
pub subtitle_language: Option<String>,
|
||||
pub subtitles: bool,
|
||||
/// The name the muxer gave the chosen audio track, when it had one. What
|
||||
/// picks between two tracks in the same language on the next episode.
|
||||
#[serde(default)]
|
||||
pub audio_title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subtitle_title: Option<String>,
|
||||
}
|
||||
impl Default for TitleTrackPrefs {
|
||||
fn default() -> Self {
|
||||
@@ -164,10 +177,26 @@ impl Default for TitleTrackPrefs {
|
||||
audio_language: None,
|
||||
subtitle_language: None,
|
||||
subtitles: true,
|
||||
audio_title: None,
|
||||
subtitle_title: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `title_track_prefs` row, its columns starting at `first`.
|
||||
fn title_track_prefs_row(
|
||||
row: &rusqlite::Row<'_>,
|
||||
first: usize,
|
||||
) -> rusqlite::Result<TitleTrackPrefs> {
|
||||
Ok(TitleTrackPrefs {
|
||||
audio_language: row.get(first)?,
|
||||
subtitle_language: row.get(first + 1)?,
|
||||
subtitles: row.get::<_, i64>(first + 2)? != 0,
|
||||
audio_title: row.get(first + 3)?,
|
||||
subtitle_title: row.get(first + 4)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// What `season = -1` means in `episode_metadata`: no season at all.
|
||||
const ABSOLUTE_SEASON: i64 = -1;
|
||||
|
||||
@@ -297,6 +326,12 @@ impl Catalog {
|
||||
tx.pragma_update(None, "user_version", 7)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if version < 8 {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute_batch(MIGRATION_V8)?;
|
||||
tx.pragma_update(None, "user_version", 8)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -510,27 +545,43 @@ impl Catalog {
|
||||
}
|
||||
|
||||
pub fn title_track_prefs(&self, title_key: &str) -> Result<Option<TitleTrackPrefs>> {
|
||||
self.conn.query_row("SELECT audio_language, subtitle_language, subtitles FROM title_track_prefs WHERE title_key=?1", params![title_key], |r| Ok(TitleTrackPrefs { audio_language:r.get(0)?, subtitle_language:r.get(1)?, subtitles:r.get::<_,i64>(2)? != 0 })).optional().map_err(Into::into)
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT audio_language, subtitle_language, subtitles, audio_title, subtitle_title \
|
||||
FROM title_track_prefs WHERE title_key=?1",
|
||||
params![title_key],
|
||||
|row| title_track_prefs_row(row, 0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
pub fn set_title_track_prefs(&self, title_key: &str, prefs: &TitleTrackPrefs) -> Result<()> {
|
||||
self.conn.execute("INSERT INTO title_track_prefs (title_key,audio_language,subtitle_language,subtitles) VALUES (?1,?2,?3,?4) ON CONFLICT(title_key) DO UPDATE SET audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,subtitles=excluded.subtitles", params![title_key,prefs.audio_language,prefs.subtitle_language,prefs.subtitles as i64])?;
|
||||
self.conn.execute(
|
||||
"INSERT INTO title_track_prefs \
|
||||
(title_key,audio_language,subtitle_language,subtitles,audio_title,subtitle_title) \
|
||||
VALUES (?1,?2,?3,?4,?5,?6) ON CONFLICT(title_key) DO UPDATE SET \
|
||||
audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,\
|
||||
subtitles=excluded.subtitles,audio_title=excluded.audio_title,\
|
||||
subtitle_title=excluded.subtitle_title",
|
||||
params![
|
||||
title_key,
|
||||
prefs.audio_language,
|
||||
prefs.subtitle_language,
|
||||
prefs.subtitles as i64,
|
||||
prefs.audio_title,
|
||||
prefs.subtitle_title
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn all_title_track_prefs(&self) -> Result<HashMap<String, TitleTrackPrefs>> {
|
||||
let mut statement = self.conn.prepare(
|
||||
"SELECT title_key, audio_language, subtitle_language, subtitles FROM title_track_prefs",
|
||||
"SELECT title_key, audio_language, subtitle_language, subtitles, audio_title, \
|
||||
subtitle_title FROM title_track_prefs",
|
||||
)?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
TitleTrackPrefs {
|
||||
audio_language: row.get(1)?,
|
||||
subtitle_language: row.get(2)?,
|
||||
subtitles: row.get::<_, i64>(3)? != 0,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
let rows =
|
||||
statement.query_map([], |row| Ok((row.get(0)?, title_track_prefs_row(row, 1)?)))?;
|
||||
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
||||
}
|
||||
|
||||
@@ -1391,6 +1442,30 @@ mod tests {
|
||||
assert_eq!(version, SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_titles_track_choice_round_trips_with_the_track_names() {
|
||||
let catalog = Catalog::in_memory().expect("open");
|
||||
assert_eq!(catalog.title_track_prefs("show").expect("read"), None);
|
||||
|
||||
let prefs = TitleTrackPrefs {
|
||||
audio_language: Some("jpn".into()),
|
||||
subtitle_language: Some("eng".into()),
|
||||
subtitles: true,
|
||||
audio_title: None,
|
||||
subtitle_title: Some("Full Subtitles".into()),
|
||||
};
|
||||
catalog.set_title_track_prefs("show", &prefs).expect("write");
|
||||
assert_eq!(catalog.title_track_prefs("show").expect("read"), Some(prefs.clone()));
|
||||
|
||||
let changed = TitleTrackPrefs {
|
||||
subtitle_title: Some("Signs & Songs".into()),
|
||||
..prefs
|
||||
};
|
||||
catalog.set_title_track_prefs("show", &changed).expect("overwrite");
|
||||
let all = catalog.all_title_track_prefs().expect("read all");
|
||||
assert_eq!(all.get("show"), Some(&changed));
|
||||
}
|
||||
|
||||
/// A newer schema is refused, not downgraded — reinterpreting it would
|
||||
/// silently mangle whatever the newer build recorded.
|
||||
#[test]
|
||||
|
||||
@@ -52,4 +52,4 @@ pub use player::{
|
||||
};
|
||||
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
||||
pub use render::VideoRenderer;
|
||||
pub use tracks::{Track, TrackKind, language_name};
|
||||
pub use tracks::{Track, TrackKind, language_name, pick_track};
|
||||
@@ -107,6 +107,56 @@ pub(crate) fn read(mpv: &Mpv) -> Vec<Track> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The track of `kind` that matches a choice remembered from another file.
|
||||
///
|
||||
/// A title outranks a language: two English subtitle tracks — "Signs & Songs"
|
||||
/// and "Full Subtitles" — share a language and differ only by name, and the
|
||||
/// name is what was picked. A title in the wrong language is not a match, since
|
||||
/// "Full Subtitles" in German is not what someone reading English chose. Without
|
||||
/// a title match, a track in the language wins, the container's default first.
|
||||
/// `None` when nothing matches, which leaves the current selection standing.
|
||||
pub fn pick_track(
|
||||
tracks: &[Track],
|
||||
kind: TrackKind,
|
||||
language: Option<&str>,
|
||||
title: Option<&str>,
|
||||
) -> Option<i64> {
|
||||
let in_language = |track: &Track| match (language, track.language.as_deref()) {
|
||||
(None, _) => true,
|
||||
(Some(wanted), Some(have)) => same_language(wanted, have),
|
||||
(Some(_), None) => false,
|
||||
};
|
||||
let of_kind = || tracks.iter().filter(move |track| track.kind == kind);
|
||||
|
||||
if let Some(title) = title
|
||||
&& let Some(track) = of_kind().find(|track| {
|
||||
in_language(track)
|
||||
&& track
|
||||
.title
|
||||
.as_deref()
|
||||
.is_some_and(|have| have.eq_ignore_ascii_case(title))
|
||||
})
|
||||
{
|
||||
return Some(track.id);
|
||||
}
|
||||
|
||||
language?;
|
||||
let mut matching = of_kind().filter(|track| in_language(track));
|
||||
let first = matching.next()?;
|
||||
Some(
|
||||
std::iter::once(first)
|
||||
.chain(matching)
|
||||
.find(|track| track.default)
|
||||
.unwrap_or(first)
|
||||
.id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether two container tags name the same language: "en" and "eng" do.
|
||||
fn same_language(a: &str, b: &str) -> bool {
|
||||
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b)
|
||||
}
|
||||
|
||||
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
|
||||
let kind = TrackKind::from_mpv(&text(mpv, index, "type")?)?;
|
||||
let id: i64 = mpv
|
||||
@@ -242,6 +292,87 @@ mod tests {
|
||||
assert_eq!(language_name("mis"), "mis");
|
||||
}
|
||||
|
||||
fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track {
|
||||
Track {
|
||||
language: language.map(Into::into),
|
||||
title: title.map(Into::into),
|
||||
..track(kind, id)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remembered_title_picks_between_tracks_of_one_language() {
|
||||
let tracks = [
|
||||
named(TrackKind::Subtitle, 1, Some("eng"), Some("Signs & Songs")),
|
||||
named(TrackKind::Subtitle, 2, Some("eng"), Some("Full Subtitles")),
|
||||
];
|
||||
let mut signs = tracks[0].clone();
|
||||
signs.default = true;
|
||||
let tracks = [signs, tracks[1].clone()];
|
||||
assert_eq!(
|
||||
pick_track(
|
||||
&tracks,
|
||||
TrackKind::Subtitle,
|
||||
Some("eng"),
|
||||
Some("full subtitles")
|
||||
),
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_in_another_language_is_not_the_one_that_was_chosen() {
|
||||
let tracks = [
|
||||
named(TrackKind::Subtitle, 1, Some("ger"), Some("Full Subtitles")),
|
||||
named(TrackKind::Subtitle, 2, Some("en"), Some("Dialogue")),
|
||||
];
|
||||
// Falls back to the language, and "en" is the same language as "eng".
|
||||
assert_eq!(
|
||||
pick_track(
|
||||
&tracks,
|
||||
TrackKind::Subtitle,
|
||||
Some("eng"),
|
||||
Some("Full Subtitles")
|
||||
),
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_track_is_found_by_its_title_alone() {
|
||||
let tracks = [
|
||||
named(TrackKind::Audio, 1, None, Some("Stereo")),
|
||||
named(TrackKind::Audio, 2, None, Some("Commentary")),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_track(&tracks, TrackKind::Audio, None, Some("Commentary")),
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_language_match_prefers_the_default_track_and_nothing_matching_is_none() {
|
||||
let mut second = named(TrackKind::Audio, 2, Some("jpn"), None);
|
||||
second.default = true;
|
||||
let tracks = [
|
||||
named(TrackKind::Audio, 1, Some("jpn"), Some("Commentary")),
|
||||
second,
|
||||
named(TrackKind::Subtitle, 3, Some("jpn"), None),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_track(&tracks, TrackKind::Audio, Some("ja"), None),
|
||||
Some(2)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_track(&tracks, TrackKind::Audio, Some("fre"), None),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
pick_track(&tracks, TrackKind::Audio, None, Some("Stereo")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_kinds_the_player_can_select_are_tracks() {
|
||||
assert_eq!(TrackKind::from_mpv("audio"), Some(TrackKind::Audio));
|
||||
|
||||
@@ -55,6 +55,17 @@ chosen by hand sets `alang`/`slang` for every file loaded afterwards — the poi
|
||||
being that picking Japanese audio once picks it for the rest of the season.
|
||||
A dragged volume slider writes to disk on release, not per frame.
|
||||
|
||||
Done: **the track choice follows the title, by name as well as language.** A
|
||||
track picked by hand is stored per title in the catalog (`title_track_prefs`,
|
||||
schema v8 added `audio_title`/`subtitle_title`) as its language *and* the name
|
||||
the muxer gave it. On `FileLoaded`, `playback::restore_tracks` re-selects the
|
||||
track whose name matches (`pstr_player::pick_track`) — which is what tells
|
||||
"Signs & Songs" from "Full Subtitles" when both are English, and finds an
|
||||
untagged track at all; `alang`/`slang` could only ever say the language. A name
|
||||
in the wrong language is not a match, and a title with only a language stored
|
||||
keeps mpv's own pick. Android stores languages only and keeps a desktop-stored
|
||||
name while its language is unchanged.
|
||||
|
||||
Done: the player page's overlay — a full-width seek bar with the file's chapters
|
||||
marked on it and the current chapter named beside the clock, a transport cluster
|
||||
(previous / −10s / play / +30s / next), and volume plus the audio, subtitle and
|
||||
|
||||
Reference in new issue
Block a user