mirror of
https://github.com/yattee/yattee.git
synced 2025-07-07 04:00:05 +00:00
feat: default lang and mpv audio track switching
This commit is contained in:
parent
2a597ab3cb
commit
2461a33feb
@ -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 {
|
||||
|
@ -11,6 +11,7 @@ enum LanguageCodes: String, CaseIterable {
|
||||
case Greek = "el"
|
||||
case English = "en"
|
||||
case English_GB = "en-GB"
|
||||
case English_US = "en-US"
|
||||
case Spanish = "es"
|
||||
case Persian = "fa"
|
||||
case Finnish = "fi"
|
||||
@ -76,6 +77,8 @@ enum LanguageCodes: String, CaseIterable {
|
||||
return "English"
|
||||
case .English_GB:
|
||||
return "English (United Kingdom)"
|
||||
case .English_US:
|
||||
return "English (United States)"
|
||||
case .Spanish:
|
||||
return "Spanish"
|
||||
case .Persian:
|
||||
|
@ -19,6 +19,7 @@ struct ControlsOverlay: View {
|
||||
case increaseRate
|
||||
case decreaseRate
|
||||
case captions
|
||||
case audioTrack
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: Field?
|
||||
@ -60,6 +61,15 @@ struct ControlsOverlay: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
if !player.availableAudioTracks.isEmpty {
|
||||
Section(header: controlsHeader("Audio Track".localized())) {
|
||||
audioTrackButton
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .audioTrack)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: controlsHeader("Stream & Player".localized())) {
|
||||
qualityButton
|
||||
#if os(tvOS)
|
||||
@ -438,6 +448,46 @@ struct ControlsOverlay: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private var audioTrackButton: some View {
|
||||
#if os(macOS)
|
||||
audioTrackPicker
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 300)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
audioTrackPicker
|
||||
} label: {
|
||||
Text(player.availableAudioTracks[player.selectedAudioTrackIndex].displayLanguage)
|
||||
.frame(maxWidth: 240, alignment: .trailing)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(maxWidth: 240, alignment: .trailing)
|
||||
.frame(height: 40)
|
||||
#else
|
||||
ControlsOverlayButton(focusedField: $focusedField, field: .audioTrack) {
|
||||
Text(player.availableAudioTracks[player.selectedAudioTrackIndex].displayLanguage)
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
.contextMenu {
|
||||
ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in
|
||||
Button(track.description) { player.selectedAudioTrackIndex = index }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var audioTrackPicker: some View {
|
||||
Picker("", selection: $player.selectedAudioTrackIndex) {
|
||||
ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in
|
||||
Text(track.description).tag(index)
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
}
|
||||
|
||||
struct ControlsOverlay_Previews: PreviewProvider {
|
||||
|
@ -22,6 +22,7 @@ struct PlaybackSettings: View {
|
||||
case increaseRate
|
||||
case decreaseRate
|
||||
case captions
|
||||
case audioTrack
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: Field?
|
||||
@ -112,6 +113,17 @@ struct PlaybackSettings: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
if !player.availableAudioTracks.isEmpty {
|
||||
HStack {
|
||||
controlsHeader("Audio Track".localized())
|
||||
Spacer()
|
||||
audioTrackButton
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .audioTrack)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
controlsHeader("Backend".localized())
|
||||
Spacer()
|
||||
@ -453,6 +465,46 @@ struct PlaybackSettings: View {
|
||||
}
|
||||
.disabled(captions.isEmpty)
|
||||
}
|
||||
|
||||
@ViewBuilder private var audioTrackButton: some View {
|
||||
#if os(macOS)
|
||||
audioTrackPicker
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 300)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
audioTrackPicker
|
||||
} label: {
|
||||
Text(player.availableAudioTracks[player.selectedAudioTrackIndex].displayLanguage)
|
||||
.frame(maxWidth: 240, alignment: .trailing)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(maxWidth: 240, alignment: .trailing)
|
||||
.frame(height: 40)
|
||||
#else
|
||||
ControlsOverlayButton(focusedField: $focusedField, field: .audioTrack) {
|
||||
Text(player.availableAudioTracks[player.selectedAudioTrackIndex].displayLanguage)
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
.contextMenu {
|
||||
ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in
|
||||
Button(track.description) { player.selectedAudioTrackIndex = index }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var audioTrackPicker: some View {
|
||||
Picker("", selection: $player.selectedAudioTrackIndex) {
|
||||
ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in
|
||||
Text(track.description).tag(index)
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaybackSettings_Previews: PreviewProvider {
|
||||
|
Loading…
x
Reference in New Issue
Block a user