feat: support YouTube VTT subtitle selection
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user