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:
nvrl committed 2026-10-14 18:18:40 +02:00
1 parent 8d615579e2
commit b1ea076ea3
10 files changed
+353 -7

No files matched your search

+10 -1
View File
@@ -33,6 +33,10 @@ struct MpvTrack: Identifiable, Hashable {
let language: String
let title: String
let selected: Bool
/// The container marks it as the default choice.
var isDefault = false
/// The container marks it as forced: on-screen text only, by convention.
var forced = false
var label: String {
let parts = [language, title].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
@@ -91,6 +95,8 @@ final class MpvPlayer {
/// Called when the duration or the pause state changes: what the lock
/// screen shows, and whether the screen may sleep.
@ObservationIgnored var onStateChanged: (() -> Void)?
/// Called once per file, when mpv has loaded it and picked its tracks.
@ObservationIgnored var onLoaded: (() -> Void)?
@ObservationIgnored let layer = MpvMetalLayer()
@ObservationIgnored private var mpv: OpaquePointer?
@@ -473,6 +479,7 @@ final class MpvPlayer {
let ready = pendingSubtitles
pendingSubtitles = []
attach(ready)
onLoaded?()
}
case .restarted:
seeking = false
@@ -511,7 +518,9 @@ final class MpvPlayer {
type: getString(base + "type") ?? "",
language: getString(base + "lang") ?? "",
title: getString(base + "title") ?? "",
selected: getString(base + "selected") == "yes"
selected: getString(base + "selected") == "yes",
isDefault: getString(base + "default") == "yes",
forced: getString(base + "forced") == "yes"
)
}
}
+13
View File
@@ -256,6 +256,7 @@ final class PlayerHost {
player?.onEnded = { [weak self] reason, media in self?.ended(reason, media) }
player?.onTick = { [weak self] in self?.tick() }
player?.onStateChanged = { [weak self] in self?.publishNowPlaying() }
player?.onLoaded = { [weak self] in self?.preferFullSubtitles() }
}
guard let player else {
playbackError = "Playback is unavailable: libmpv did not start."
@@ -433,6 +434,18 @@ final class PlayerHost {
chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil)
}
/// Play the dialogue rather than the signs and songs mpv picked by
/// language (`pstr_core::subtitles`). Not remembered: it is the choice mpv
/// should have made, not the viewer's.
private func preferFullSubtitles() {
guard let player, playingOpenEpisode else { return }
let subtitles = player.tracks.filter { $0.type == "sub" }.map {
SubtitleTrackRecord(id: $0.id, language: $0.language, title: $0.title, default: $0.isDefault, forced: $0.forced, selected: $0.selected)
}
guard let id = fullSubtitlesInstead(tracks: subtitles), let track = player.tracks.first(where: { $0.type == "sub" && $0.id == id }) else { return }
player.select(type: "sub", track: track)
}
/// The skip button's offer at the current position, if any.
var offer: SkipOffer? {
guard let player, playingOpenEpisode else { return nil }