mirror of
https://github.com/yattee/yattee.git
synced 2025-08-09 20:24:06 +00:00
feat: default lang and mpv audio track switching
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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 ?? []
|
||||
}
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
Reference in New Issue
Block a user