feat: support YouTube VTT subtitle selection

This commit is contained in:
2026-10-27 13:05:56 +01:00
parent a2c8cc8e4d
commit 0641859d95
12 changed files with 449 additions and 25 deletions
+26 -8
View File
@@ -201,6 +201,10 @@ final class PlayerModel: ObservableObject {
@Published private(set) var subtitleError: String?
private var subtitleDocument: WebVTTDocument?
private var subtitleTask: Task<Void, Never>?
var canSelectSubtitle: Bool {
if case .youtube? = currentMedia, castDevice != nil { return false }
return !subtitleTracks.isEmpty
}
@Published var seekPosition = 0.0
@Published var isSeeking = false
@Published private(set) var volume = 1.0
@@ -380,7 +384,9 @@ final class PlayerModel: ObservableObject {
guard canCast, let source = currentMedia, let handle else { return }
cancelStreamResolution()
commandError = nil
casting.start(to: device, source: source, title: fileName ?? "Terebi", subtitle: selectedSubtitle,
// YouTube captions remain local-only: never prefetch optional tracks during Cast handoff.
let subtitle: Int? = if case .youtube = source { nil } else { selectedSubtitle }
casting.start(to: device, source: source, title: fileName ?? "Terebi", subtitle: subtitle,
handle: handle, snapshot: state, playing: playing ?? effectivePlaying,
volume: volume, audibleVolume: lastAudibleVolume)
revealControls()
@@ -404,7 +410,7 @@ final class PlayerModel: ObservableObject {
state = projection.state
volume = projection.volume
if volume > 0 { lastAudibleVolume = volume }
selectedSubtitle = projection.subtitle
if case .resolved? = currentMedia { selectedSubtitle = projection.subtitle }
if !isSeeking && !state.seek_pending { seekPosition = state.position }
if let handle { _ = player_report_remote_state(handle, &state) }
saveProgress()
@@ -597,6 +603,11 @@ final class PlayerModel: ObservableObject {
private func resolveStream(_ stream: StreamURL, change: MediaChange) {
replaceMetadata(for: nil)
if !change.isQuality {
if castDevice != nil { stopCasting(resume: false) }
selectSubtitle(nil)
subtitleTracks = []
}
switch stream {
case .youtube(let url): resolveYouTube(url, height: nil, change: change)
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode, change: change)
@@ -620,6 +631,7 @@ final class PlayerModel: ObservableObject {
func selectSubtitle(_ index: Int?) {
if casting.active {
guard canSelectSubtitle else { return }
if !casting.setSubtitle(index) { commandError = "Could not change Cast subtitles." }
return
}
@@ -1103,16 +1115,22 @@ final class PlayerModel: ObservableObject {
if let fileName, !source.url.isFileURL, let id = queue.currentID {
queue.updateLabel(fileName, for: id)
}
let previousSubtitleLanguage = selectedSubtitle.flatMap {
subtitleTracks.indices.contains($0) ? subtitleTracks[$0].language : nil
let previousSubtitle = selectedSubtitle.flatMap {
subtitleTracks.indices.contains($0) ? subtitleTracks[$0] : nil
}
selectSubtitle(nil)
if case .resolved(let resolved, _) = source {
subtitleTracks = resolved.subtitles
let defaultIndex = subtitleTracks.firstIndex(where: \.isDefault)
selectSubtitle(change.isQuality ? previousSubtitleLanguage.flatMap { language in
subtitleTracks.firstIndex { $0.language == language } ?? defaultIndex
selectSubtitle(change.isQuality ? previousSubtitle.flatMap { previous in
subtitleTracks.firstIndex { $0.language == previous.language } ?? defaultIndex
} : defaultIndex)
} else if case .youtube(let video, _) = source {
subtitleTracks = video.subtitles
// Off and a missing track both stay Off; never substitute manual/auto by language.
if change.preservesPosition, let id = previousSubtitle?.selectionID {
selectSubtitle(subtitleTracks.firstIndex { $0.selectionID == id })
}
} else {
subtitleTracks = []
}
@@ -2987,6 +3005,7 @@ private struct CaptionSelector: NSViewRepresentable {
required init?(coder: NSCoder) { nil }
func updateTracks() {
isEnabled = model.canSelectSubtitle
guard menu == nil || displayedSelection != model.selectedSubtitle ||
displayedTracks.count != model.subtitleTracks.count ||
zip(displayedTracks, model.subtitleTracks).contains(where: {
@@ -3013,7 +3032,6 @@ private struct CaptionSelector: NSViewRepresentable {
}
menu = tracks
(cell as? NSPopUpButtonCell)?.menuItem = captionItem
isEnabled = !model.subtitleTracks.isEmpty
}
@objc private func selectTrack(_ item: NSMenuItem) {
@@ -3457,7 +3475,7 @@ private struct PlayerContent: View {
}
}
}
.disabled(model.subtitleTracks.isEmpty)
.disabled(!model.canSelectSubtitle)
Divider()
}
PlayerActionButton(model: model, action: model.bufferInfoAction,