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
+4 -1
View File
@@ -7,6 +7,8 @@ struct ResolvedSubtitle: Decodable, Sendable {
let language: String
let label: String
let isDefault: Bool
// Extractor identity survives expiring URLs and distinguishes manual from automatic captions.
var selectionID: String? = nil
private let headerFields: [String: String]
var proxy: MiruroProxy? = nil
var headers: String {
@@ -15,7 +17,7 @@ struct ResolvedSubtitle: Decodable, Sendable {
fileprivate var validatedHeaders: [String: String] { headerFields }
init(url: String, format: String, language: String, label: String, isDefault: Bool,
headers: [String: String], proxy: MiruroProxy? = nil) {
headers: [String: String], proxy: MiruroProxy? = nil, selectionID: String? = nil) {
self.url = url
self.format = format
self.language = language
@@ -23,6 +25,7 @@ struct ResolvedSubtitle: Decodable, Sendable {
self.isDefault = isDefault
self.headerFields = headers
self.proxy = proxy
self.selectionID = selectionID
}
private enum CodingKeys: String, CodingKey {
case url, format, language, label
+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,
+107 -2
View File
@@ -23,6 +23,7 @@ struct YouTubeVideo: Sendable {
let height: Int?
let availableHeights: [Int]
let chapters: [YouTubeChapter]
var subtitles: [ResolvedSubtitle] = []
func chapterBoundaries(duration: Double) -> [Double] {
guard duration.isFinite, duration > 0 else { return [] }
@@ -157,13 +158,15 @@ enum YouTubeResolver {
let video = try stream(videoFormat, fallbackHeaders: metadata.http_headers)
let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) }
let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines)
let isLive = metadata.is_live == true || metadata.live_status == "is_live"
return YouTubeVideo(
title: title?.isEmpty == false ? title! : "Video",
video: video, audio: audio, duration: duration,
isLive: metadata.is_live == true || metadata.live_status == "is_live",
isLive: isLive,
webpageURL: try validateURL(webpageURL.absoluteString),
height: videoFormat.height, availableHeights: availableHeights,
chapters: metadata.chapters?.values ?? []
chapters: metadata.chapters?.values ?? [],
subtitles: isLive ? [] : subtitles(metadata)
)
}
@@ -297,6 +300,58 @@ enum YouTubeResolver {
return video
}
private static func subtitles(_ metadata: YouTubeMetadata) -> [ResolvedSubtitle] {
func track(_ variants: [YouTubeCaption], key: String, automatic: Bool) -> ResolvedSubtitle? {
let language = automatic && key.hasSuffix("-orig") ? String(key.dropLast(5)) : key
for variant in variants where variant.ext == "vtt" {
guard variant.transport == nil || ["http", "https"].contains(variant.transport!) else { continue }
if automatic,
URLComponents(string: variant.url)?.queryItems?.contains(where: { $0.name == "tlang" }) == true {
continue
}
if automatic && !key.hasSuffix("-orig") {
// Nontranslatable ASR may have no -orig alias. Never infer it from a label.
guard let url = URLComponents(string: variant.url),
let host = url.host, ["www.youtube.com", "youtube.com"].contains(host),
url.path == "/api/timedtext",
url.queryItems?.contains(where: { $0.name == "kind" && $0.value == "asr" }) == true,
url.queryItems?.contains(where: { $0.name == "lang" && $0.value == language }) == true else { continue }
}
let name = variant.name?.trimmingCharacters(in: .whitespacesAndNewlines)
let label = name?.isEmpty == false ? name! : language
let subtitle = ResolvedSubtitle(url: variant.url, format: "vtt", language: language,
label: automatic ? "\(label) (auto-generated)" : label, isDefault: false,
headers: variant.headers ?? metadata.http_headers ?? [:],
selectionID: "youtube:\(automatic ? "auto" : "manual"):\(language)")
if ResolvedSource.validSubtitles([subtitle]) { return subtitle }
}
return nil
}
let captions = metadata.automatic_captions?.values ?? [:]
// Explicit originals win over compatibility aliases, even when their signed URLs differ.
let autoKeys = captions.keys.sorted {
if $0.hasSuffix("-orig") != $1.hasSuffix("-orig") { return $0.hasSuffix("-orig") }
return $0 < $1
}
var originals: [ResolvedSubtitle] = []
var languages = Set<String>()
var urls = Set<String>()
for key in autoKeys {
guard let subtitle = track(captions[key]!, key: key, automatic: true),
languages.insert(subtitle.language).inserted,
urls.insert(subtitle.url).inserted else { continue }
originals.append(subtitle)
if originals.count == 32 { break }
}
var manual: [ResolvedSubtitle] = []
let supplied = metadata.subtitles?.values ?? [:]
for key in supplied.keys.sorted() {
if manual.count == 32 - originals.count { break }
if let subtitle = track(supplied[key]!, key: key, automatic: false) { manual.append(subtitle) }
}
return manual + originals
}
private static func stream(_ format: YouTubeMetadata, fallbackHeaders: [String: String]?) throws -> YouTubeStream {
guard format.protocol == nil || ["http", "https", "m3u8", "m3u8_native"].contains(format.protocol!) else {
throw YouTubeResolutionError(message: "yt-dlp selected a format that requires downloading and assembling fragments instead of native streaming.")
@@ -415,12 +470,62 @@ private struct YouTubeMetadata: Decodable {
let http_headers: [String: String]?
let requested_formats: [YouTubeMetadata]?
let chapters: YouTubeChapters?
let subtitles: YouTubeCaptions?
let automatic_captions: YouTubeCaptions?
// Generic extractors may omit codec hints; FFmpeg probes the actual streams.
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? true }
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? true }
}
private struct YouTubeCaption: Decodable {
let ext: String
let url: String
let name: String?
let headers: [String: String]?
let transport: String?
private enum CodingKeys: String, CodingKey { case ext, url, name, http_headers, `protocol` }
init(from decoder: Decoder) throws {
let row = try decoder.container(keyedBy: CodingKeys.self)
ext = try row.decode(String.self, forKey: .ext)
url = try row.decode(String.self, forKey: .url)
name = try? row.decode(String.self, forKey: .name)
headers = try row.decodeIfPresent([String: String].self, forKey: .http_headers)
transport = try row.decodeIfPresent(String.self, forKey: .protocol)
}
}
private struct YouTubeCaptions: Decodable {
let values: [String: [YouTubeCaption]]
private struct LanguageKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init?(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { return nil }
}
init(from decoder: Decoder) {
guard let languages = try? decoder.container(keyedBy: LanguageKey.self) else {
values = [:]
return
}
var captions: [String: [YouTubeCaption]] = [:]
for key in languages.allKeys {
guard var rows = try? languages.nestedUnkeyedContainer(forKey: key) else { continue }
var variants: [YouTubeCaption] = []
while !rows.isAtEnd {
guard let row = try? rows.superDecoder() else { break }
if let caption = try? YouTubeCaption(from: row) { variants.append(caption) }
}
captions[key.stringValue] = variants
}
values = captions
}
}
private struct YouTubeChapters: Decodable {
let values: [YouTubeChapter]