mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(core,app,android,ios): play the dialogue, not the signs and songs
Anime releases often carry two subtitle tracks in one language, the dialogue and a "Signs & Songs" track, and the second is as often as not the one marked default or forced. mpv, picking by language, played it. pstr_core::subtitles reads a track's name and forced flag for whether it is the dialogue, and every client asks it once per file, after mpv has made its own pick, whether to switch to a track in the same language that is. A subtitle track the viewer chose by name stays, and subtitles that are off stay off.
This commit is contained in:
1 parent
8d615579e2
commit
b1ea076ea3
10 files changed
+353
-7
No files matched your search
@@ -21,6 +21,7 @@ pub mod prefs;
|
||||
pub mod profile;
|
||||
pub mod shares;
|
||||
pub mod shelves;
|
||||
pub mod subtitles;
|
||||
pub mod sync;
|
||||
|
||||
pub use account::{Account, AccountStore};
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Which of a file's subtitle tracks is the one to read.
|
||||
//!
|
||||
//! Anime releases often carry two subtitle tracks in one language: the
|
||||
//! dialogue, and a "Signs & Songs" track that only translates the on-screen
|
||||
//! text and the lyrics. The second is the one marked default or forced as
|
||||
//! often as not, and mpv, picking by language, plays it — an episode where
|
||||
//! nobody seems to speak. A language cannot tell the two apart; the track's
|
||||
//! name and its forced flag can.
|
||||
//!
|
||||
//! Nothing here talks to a player. Every client reads the track list out of
|
||||
//! mpv once a file is loaded and asks [`full_instead`] whether to switch.
|
||||
|
||||
use crate::language::same_language;
|
||||
|
||||
/// One subtitle track of the open file, as mpv lists it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SubtitleTrack {
|
||||
/// mpv's id for it, what `sid` is set to.
|
||||
pub id: i64,
|
||||
pub language: Option<String>,
|
||||
/// The name the muxer gave it: "Dialogue", "Signs & Songs".
|
||||
pub title: Option<String>,
|
||||
/// The container marks it as the default choice.
|
||||
pub default: bool,
|
||||
/// The container marks it as forced: on-screen text only, by convention.
|
||||
pub forced: bool,
|
||||
/// It is the one playing.
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
/// How much of what is said a track carries, as far as its name and flags say.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Coverage {
|
||||
/// Signs, songs, or forced: not the dialogue.
|
||||
Partial,
|
||||
/// Nothing says either way.
|
||||
Unknown,
|
||||
/// Named as the dialogue: "Dialogue", "Full Subtitles", "SDH".
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Words in a track's name that make it the whole dialogue. They win over the
|
||||
/// partial ones: "Full (with Signs)" is still the full track.
|
||||
const FULL_WORDS: [&str; 6] = ["full", "dialogue", "dialog", "dialogues", "sdh", "cc"];
|
||||
|
||||
/// Words in a track's name that make it something other than the dialogue.
|
||||
const PARTIAL_WORDS: [&str; 13] = [
|
||||
"sign", "signs", "song", "songs", "s&s", "forced", "karaoke", "lyrics", "op", "ed", "oped",
|
||||
"opening", "ending",
|
||||
];
|
||||
|
||||
impl SubtitleTrack {
|
||||
pub fn coverage(&self) -> Coverage {
|
||||
let title = self.title.as_deref().unwrap_or_default().to_lowercase();
|
||||
let words: Vec<&str> = title
|
||||
.split(|c: char| !c.is_alphanumeric() && c != '&')
|
||||
.filter(|word| !word.is_empty())
|
||||
.collect();
|
||||
let has = |list: &[&str]| words.iter().any(|word| list.contains(word));
|
||||
if has(&FULL_WORDS) {
|
||||
Coverage::Full
|
||||
} else if self.forced || has(&PARTIAL_WORDS) {
|
||||
Coverage::Partial
|
||||
} else {
|
||||
Coverage::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The track to play in place of the one mpv chose, if it chose the wrong one.
|
||||
///
|
||||
/// Only a track in the same language as the playing one is considered — the
|
||||
/// language is the viewer's choice, and this only corrects which of its tracks
|
||||
/// was taken. The playing track is replaced when it is partial and the file has
|
||||
/// one in its language that is not, or when nothing names it and another is
|
||||
/// named as the dialogue. Among the rest, a track named as the dialogue comes
|
||||
/// first, then the container's default, then the file's order. `None` leaves
|
||||
/// the selection as it is, which includes subtitles being off.
|
||||
pub fn full_instead(tracks: &[SubtitleTrack]) -> Option<i64> {
|
||||
let playing = tracks.iter().find(|track| track.selected)?;
|
||||
let current = playing.coverage();
|
||||
if current == Coverage::Full {
|
||||
return None;
|
||||
}
|
||||
let same = |track: &SubtitleTrack| match (&playing.language, &track.language) {
|
||||
(Some(a), Some(b)) => same_language(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
tracks
|
||||
.iter()
|
||||
.filter(|track| track.id != playing.id && same(track))
|
||||
.filter(|track| track.coverage() > current)
|
||||
// `max_by_key` keeps the last of equals; reversed, the first.
|
||||
.rev()
|
||||
.max_by_key(|track| (track.coverage(), track.default))
|
||||
.map(|track| track.id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn track(id: i64, language: &str, title: &str) -> SubtitleTrack {
|
||||
SubtitleTrack {
|
||||
id,
|
||||
language: Some(language.into()),
|
||||
title: (!title.is_empty()).then(|| title.into()),
|
||||
..SubtitleTrack::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn playing(mut track: SubtitleTrack) -> SubtitleTrack {
|
||||
track.selected = true;
|
||||
track
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signs_and_songs_playing_gives_way_to_the_dialogue_in_its_language() {
|
||||
let tracks = [
|
||||
playing(track(1, "eng", "Signs & Songs")),
|
||||
track(2, "eng", "Dialogue"),
|
||||
];
|
||||
assert_eq!(full_instead(&tracks), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unnamed_track_gives_way_to_one_named_as_the_dialogue() {
|
||||
let tracks = [playing(track(1, "en", "")), track(2, "en", "Dialogue")];
|
||||
assert_eq!(full_instead(&tracks), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forced_track_gives_way_to_an_unnamed_one_and_the_default_comes_first() {
|
||||
let mut forced = playing(track(1, "eng", "English"));
|
||||
forced.forced = true;
|
||||
let mut default = track(3, "eng", "");
|
||||
default.default = true;
|
||||
let tracks = [forced, track(2, "eng", ""), default];
|
||||
assert_eq!(full_instead(&tracks), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_candidates_resolve_to_the_first_in_the_file() {
|
||||
let tracks = [
|
||||
playing(track(1, "eng", "Songs")),
|
||||
track(2, "eng", ""),
|
||||
track(3, "eng", ""),
|
||||
];
|
||||
assert_eq!(full_instead(&tracks), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_track_in_another_language_is_never_switched_to() {
|
||||
let tracks = [
|
||||
playing(track(1, "eng", "Signs")),
|
||||
track(2, "ger", "Dialogue"),
|
||||
];
|
||||
assert_eq!(full_instead(&tracks), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_letter_and_three_letter_tags_are_one_language() {
|
||||
let tracks = [playing(track(1, "en", "OP/ED")), track(2, "eng", "")];
|
||||
assert_eq!(full_instead(&tracks), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dialogue_playing_stays_and_subtitles_off_stay_off() {
|
||||
let tracks = [
|
||||
track(1, "eng", "Signs & Songs"),
|
||||
playing(track(2, "eng", "Dialogue")),
|
||||
];
|
||||
assert_eq!(full_instead(&tracks), None);
|
||||
let off = [
|
||||
track(1, "eng", "Signs & Songs"),
|
||||
track(2, "eng", "Dialogue"),
|
||||
];
|
||||
assert_eq!(full_instead(&off), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_word_outweighs_a_partial_one_in_the_same_name() {
|
||||
assert_eq!(
|
||||
track(1, "eng", "Full (with Signs)").coverage(),
|
||||
Coverage::Full
|
||||
);
|
||||
assert_eq!(track(1, "eng", "Signs/Songs").coverage(), Coverage::Partial);
|
||||
assert_eq!(track(1, "eng", "English").coverage(), Coverage::Unknown);
|
||||
// A word inside another is not that word.
|
||||
assert_eq!(
|
||||
track(1, "eng", "Designer cut").coverage(),
|
||||
Coverage::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partial_track_with_only_partial_company_stays() {
|
||||
let tracks = [playing(track(1, "eng", "Signs")), track(2, "eng", "Songs")];
|
||||
assert_eq!(full_instead(&tracks), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user