feat: default lang and mpv audio track switching

This commit is contained in:
ned
2025-06-01 21:10:46 +02:00
parent 2a597ab3cb
commit 2461a33feb
7 changed files with 216 additions and 12 deletions

View File

@@ -685,50 +685,93 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
}
}
func extractXTags(from urlString: String) -> [String: String] {
guard let urlComponents = URLComponents(string: urlString),
let queryItems = urlComponents.queryItems,
let xtagsValue = queryItems.first(where: { $0.name == "xtags" })?.value else {
return [:]
}
guard let decoded = xtagsValue.removingPercentEncoding else { return [:] }
// Parse key-value pairs (format: key1=value1:key2=value2)
// Example: "acont=dubbed-auto:lang=en-US"
let pairs = decoded.split(separator: ":")
var result: [String: String] = [:]
for pair in pairs {
let parts = pair.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
result[String(parts[0])] = String(parts[1])
}
}
return result
}
private func extractAdaptiveFormats(from streams: [JSON], videoId: String?) -> [Stream] {
let audioStreams = streams
let audioTracks = streams
.filter { $0["type"].stringValue.starts(with: "audio/mp4") }
.sorted {
$0.dictionaryValue["bitrate"]?.int ?? 0 >
$1.dictionaryValue["bitrate"]?.int ?? 0
}
guard let audioStream = audioStreams.first else {
.compactMap { audioStream -> Stream.AudioTrack? in
guard let url = audioStream["url"].url,
let audioItag = audioStream["itag"].string
else { return nil }
let finalURL: URL
if let videoId, account.instance.invidiousCompanion {
let audioCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(audioItag)"
finalURL = URL(string: audioCompanionURLString) ?? url
} else {
finalURL = url
}
let xTags = extractXTags(from: url.absoluteString)
return Stream.AudioTrack(
url: finalURL,
content: xTags["acont"],
language: xTags["lang"]
)
}
.sorted {
/// Always prefer original audio streams over dubbed ones
!$0.isDubbed && $1.isDubbed
}
guard !audioTracks.isEmpty else {
return .init()
}
let videoStreams = streams.filter { $0["type"].stringValue.starts(with: "video/") }
return videoStreams.compactMap { videoStream in
guard let audioAssetURL = audioStream["url"].url,
let videoAssetURL = videoStream["url"].url,
let audioItag = audioStream["itag"].string,
guard let videoAssetURL = videoStream["url"].url,
let videoItag = videoStream["itag"].string
else {
return nil
}
let finalAudioURL: URL
let finalVideoURL: URL
if let videoId, account.instance.invidiousCompanion {
let audioCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(audioItag)"
let videoCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(videoItag)"
finalAudioURL = URL(string: audioCompanionURLString) ?? audioAssetURL
finalVideoURL = URL(string: videoCompanionURLString) ?? videoAssetURL
} else {
finalAudioURL = audioAssetURL
finalVideoURL = videoAssetURL
}
return Stream(
instance: account.instance,
audioAsset: AVURLAsset(url: finalAudioURL),
audioAsset: AVURLAsset(url: audioTracks[0].url),
videoAsset: AVURLAsset(url: finalVideoURL),
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
kind: .adaptive,
encoding: videoStream["encoding"].string,
videoFormat: videoStream["type"].string,
bitrate: videoStream["bitrate"].int,
requestRange: videoStream["init"].string ?? videoStream["index"].string
requestRange: videoStream["init"].string ?? videoStream["index"].string,
audioTracks: audioTracks
)
}
}

View File

@@ -185,6 +185,10 @@ final class MPVBackend: PlayerBackend {
var audioSampleRate: String {
client?.audioSampleRate ?? "unknown"
}
var availableAudioTracks: [Stream.AudioTrack] {
stream?.audioTracks ?? []
}
init() {
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
@@ -243,6 +247,9 @@ final class MPVBackend: PlayerBackend {
let updateCurrentStream = {
DispatchQueue.main.async { [weak self] in
if self?.video?.id != video.id {
self?.model.selectedAudioTrackIndex = 0
}
self?.stream = stream
self?.video = video
self?.model.stream = stream
@@ -319,6 +326,7 @@ final class MPVBackend: PlayerBackend {
startPlaying()
}
stream.audioAsset = AVURLAsset(url: stream.audioTracks[stream.selectedAudioTrackIndex].url)
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
@@ -728,4 +736,13 @@ final class MPVBackend: PlayerBackend {
logger.info("MPV backend received unhandled property: \(name)")
}
}
func switchAudioTrack(to index: Int) {
guard let stream, let video else { return }
stream.selectedAudioTrackIndex = index
model.saveTime { [weak self] in
self?.playStream(stream, of: video, preservingTime: true, upgrading: false)
}
}
}

View File

@@ -210,6 +210,14 @@ final class PlayerModel: ObservableObject {
var keyPressMonitor: Any?
#endif
@Published var selectedAudioTrackIndex = 0 {
didSet {
if oldValue != selectedAudioTrackIndex {
handleAudioTrackChange()
}
}
}
init() {
#if os(iOS)
isOrientationLocked = Defaults[.isOrientationLocked]
@@ -1467,4 +1475,12 @@ final class PlayerModel: ObservableObject {
}
}
#endif
private func handleAudioTrackChange() {
(backend as? MPVBackend)?.switchAudioTrack(to: selectedAudioTrackIndex)
}
var availableAudioTracks: [Stream.AudioTrack] {
(backend as? MPVBackend)?.availableAudioTracks ?? []
}
}

View File

@@ -191,6 +191,25 @@ class Stream: Equatable, Hashable, Identifiable {
return .unknown
}
}
struct AudioTrack: Hashable, Identifiable {
let id = UUID().uuidString
let url: URL
let content: String?
let language: String?
var displayLanguage: String {
LanguageCodes(rawValue: language ?? "")?.description.capitalized ?? language ?? "Unknown"
}
var description: String {
"\(displayLanguage) (\(content ?? "Unknown"))"
}
var isDubbed: Bool {
content?.lowercased().starts(with: "dubbed") ?? false
}
}
let id = UUID()
@@ -208,6 +227,8 @@ class Stream: Equatable, Hashable, Identifiable {
var videoFormat: String?
var bitrate: Int?
var requestRange: String?
var audioTracks: [AudioTrack] = []
var selectedAudioTrackIndex = 0
init(
instance: Instance? = nil,
@@ -220,7 +241,8 @@ class Stream: Equatable, Hashable, Identifiable {
encoding: String? = nil,
videoFormat: String? = nil,
bitrate: Int? = nil,
requestRange: String? = nil
requestRange: String? = nil,
audioTracks: [AudioTrack] = []
) {
self.instance = instance
self.audioAsset = audioAsset
@@ -233,6 +255,7 @@ class Stream: Equatable, Hashable, Identifiable {
format = .from(videoFormat ?? "")
self.bitrate = bitrate
self.requestRange = requestRange
self.audioTracks = audioTracks
}
var isLocal: Bool {