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] {
|
private func extractAdaptiveFormats(from streams: [JSON], videoId: String?) -> [Stream] {
|
||||||
let audioStreams = streams
|
let audioTracks = streams
|
||||||
.filter { $0["type"].stringValue.starts(with: "audio/mp4") }
|
.filter { $0["type"].stringValue.starts(with: "audio/mp4") }
|
||||||
.sorted {
|
.sorted {
|
||||||
$0.dictionaryValue["bitrate"]?.int ?? 0 >
|
$0.dictionaryValue["bitrate"]?.int ?? 0 >
|
||||||
$1.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()
|
return .init()
|
||||||
}
|
}
|
||||||
|
|
||||||
let videoStreams = streams.filter { $0["type"].stringValue.starts(with: "video/") }
|
let videoStreams = streams.filter { $0["type"].stringValue.starts(with: "video/") }
|
||||||
|
|
||||||
return videoStreams.compactMap { videoStream in
|
return videoStreams.compactMap { videoStream in
|
||||||
guard let audioAssetURL = audioStream["url"].url,
|
guard let videoAssetURL = videoStream["url"].url,
|
||||||
let videoAssetURL = videoStream["url"].url,
|
|
||||||
let audioItag = audioStream["itag"].string,
|
|
||||||
let videoItag = videoStream["itag"].string
|
let videoItag = videoStream["itag"].string
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let finalAudioURL: URL
|
|
||||||
let finalVideoURL: URL
|
let finalVideoURL: URL
|
||||||
|
|
||||||
if let videoId, account.instance.invidiousCompanion {
|
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)"
|
let videoCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(videoItag)"
|
||||||
finalAudioURL = URL(string: audioCompanionURLString) ?? audioAssetURL
|
|
||||||
finalVideoURL = URL(string: videoCompanionURLString) ?? videoAssetURL
|
finalVideoURL = URL(string: videoCompanionURLString) ?? videoAssetURL
|
||||||
} else {
|
} else {
|
||||||
finalAudioURL = audioAssetURL
|
|
||||||
finalVideoURL = videoAssetURL
|
finalVideoURL = videoAssetURL
|
||||||
}
|
}
|
||||||
|
|
||||||
return Stream(
|
return Stream(
|
||||||
instance: account.instance,
|
instance: account.instance,
|
||||||
audioAsset: AVURLAsset(url: finalAudioURL),
|
audioAsset: AVURLAsset(url: audioTracks[0].url),
|
||||||
videoAsset: AVURLAsset(url: finalVideoURL),
|
videoAsset: AVURLAsset(url: finalVideoURL),
|
||||||
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
|
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
|
||||||
kind: .adaptive,
|
kind: .adaptive,
|
||||||
encoding: videoStream["encoding"].string,
|
encoding: videoStream["encoding"].string,
|
||||||
videoFormat: videoStream["type"].string,
|
videoFormat: videoStream["type"].string,
|
||||||
bitrate: videoStream["bitrate"].int,
|
bitrate: videoStream["bitrate"].int,
|
||||||
requestRange: videoStream["init"].string ?? videoStream["index"].string
|
requestRange: videoStream["init"].string ?? videoStream["index"].string,
|
||||||
|
audioTracks: audioTracks
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -186,6 +186,10 @@ final class MPVBackend: PlayerBackend {
|
|||||||
client?.audioSampleRate ?? "unknown"
|
client?.audioSampleRate ?? "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var availableAudioTracks: [Stream.AudioTrack] {
|
||||||
|
stream?.audioTracks ?? []
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
|
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
|
||||||
guard let self, self.model.activeBackend == .mpv else {
|
guard let self, self.model.activeBackend == .mpv else {
|
||||||
@ -243,6 +247,9 @@ final class MPVBackend: PlayerBackend {
|
|||||||
|
|
||||||
let updateCurrentStream = {
|
let updateCurrentStream = {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
if self?.video?.id != video.id {
|
||||||
|
self?.model.selectedAudioTrackIndex = 0
|
||||||
|
}
|
||||||
self?.stream = stream
|
self?.stream = stream
|
||||||
self?.video = video
|
self?.video = video
|
||||||
self?.model.stream = stream
|
self?.model.stream = stream
|
||||||
@ -319,6 +326,7 @@ final class MPVBackend: PlayerBackend {
|
|||||||
startPlaying()
|
startPlaying()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stream.audioAsset = AVURLAsset(url: stream.audioTracks[stream.selectedAudioTrackIndex].url)
|
||||||
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
|
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
|
||||||
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.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)")
|
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?
|
var keyPressMonitor: Any?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@Published var selectedAudioTrackIndex = 0 {
|
||||||
|
didSet {
|
||||||
|
if oldValue != selectedAudioTrackIndex {
|
||||||
|
handleAudioTrackChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
isOrientationLocked = Defaults[.isOrientationLocked]
|
isOrientationLocked = Defaults[.isOrientationLocked]
|
||||||
@ -1467,4 +1475,12 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private func handleAudioTrackChange() {
|
||||||
|
(backend as? MPVBackend)?.switchAudioTrack(to: selectedAudioTrackIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
var availableAudioTracks: [Stream.AudioTrack] {
|
||||||
|
(backend as? MPVBackend)?.availableAudioTracks ?? []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -192,6 +192,25 @@ class Stream: Equatable, Hashable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
let id = UUID()
|
||||||
|
|
||||||
var instance: Instance!
|
var instance: Instance!
|
||||||
@ -208,6 +227,8 @@ class Stream: Equatable, Hashable, Identifiable {
|
|||||||
var videoFormat: String?
|
var videoFormat: String?
|
||||||
var bitrate: Int?
|
var bitrate: Int?
|
||||||
var requestRange: String?
|
var requestRange: String?
|
||||||
|
var audioTracks: [AudioTrack] = []
|
||||||
|
var selectedAudioTrackIndex = 0
|
||||||
|
|
||||||
init(
|
init(
|
||||||
instance: Instance? = nil,
|
instance: Instance? = nil,
|
||||||
@ -220,7 +241,8 @@ class Stream: Equatable, Hashable, Identifiable {
|
|||||||
encoding: String? = nil,
|
encoding: String? = nil,
|
||||||
videoFormat: String? = nil,
|
videoFormat: String? = nil,
|
||||||
bitrate: Int? = nil,
|
bitrate: Int? = nil,
|
||||||
requestRange: String? = nil
|
requestRange: String? = nil,
|
||||||
|
audioTracks: [AudioTrack] = []
|
||||||
) {
|
) {
|
||||||
self.instance = instance
|
self.instance = instance
|
||||||
self.audioAsset = audioAsset
|
self.audioAsset = audioAsset
|
||||||
@ -233,6 +255,7 @@ class Stream: Equatable, Hashable, Identifiable {
|
|||||||
format = .from(videoFormat ?? "")
|
format = .from(videoFormat ?? "")
|
||||||
self.bitrate = bitrate
|
self.bitrate = bitrate
|
||||||
self.requestRange = requestRange
|
self.requestRange = requestRange
|
||||||
|
self.audioTracks = audioTracks
|
||||||
}
|
}
|
||||||
|
|
||||||
var isLocal: Bool {
|
var isLocal: Bool {
|
||||||
|
@ -11,6 +11,7 @@ enum LanguageCodes: String, CaseIterable {
|
|||||||
case Greek = "el"
|
case Greek = "el"
|
||||||
case English = "en"
|
case English = "en"
|
||||||
case English_GB = "en-GB"
|
case English_GB = "en-GB"
|
||||||
|
case English_US = "en-US"
|
||||||
case Spanish = "es"
|
case Spanish = "es"
|
||||||
case Persian = "fa"
|
case Persian = "fa"
|
||||||
case Finnish = "fi"
|
case Finnish = "fi"
|
||||||
@ -76,6 +77,8 @@ enum LanguageCodes: String, CaseIterable {
|
|||||||
return "English"
|
return "English"
|
||||||
case .English_GB:
|
case .English_GB:
|
||||||
return "English (United Kingdom)"
|
return "English (United Kingdom)"
|
||||||
|
case .English_US:
|
||||||
|
return "English (United States)"
|
||||||
case .Spanish:
|
case .Spanish:
|
||||||
return "Spanish"
|
return "Spanish"
|
||||||
case .Persian:
|
case .Persian:
|
||||||
|
@ -19,6 +19,7 @@ struct ControlsOverlay: View {
|
|||||||
case increaseRate
|
case increaseRate
|
||||||
case decreaseRate
|
case decreaseRate
|
||||||
case captions
|
case captions
|
||||||
|
case audioTrack
|
||||||
}
|
}
|
||||||
|
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
@ -60,6 +61,15 @@ struct ControlsOverlay: View {
|
|||||||
#endif
|
#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())) {
|
Section(header: controlsHeader("Stream & Player".localized())) {
|
||||||
qualityButton
|
qualityButton
|
||||||
#if os(tvOS)
|
#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 {
|
struct ControlsOverlay_Previews: PreviewProvider {
|
||||||
|
@ -22,6 +22,7 @@ struct PlaybackSettings: View {
|
|||||||
case increaseRate
|
case increaseRate
|
||||||
case decreaseRate
|
case decreaseRate
|
||||||
case captions
|
case captions
|
||||||
|
case audioTrack
|
||||||
}
|
}
|
||||||
|
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
@ -112,6 +113,17 @@ struct PlaybackSettings: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !player.availableAudioTracks.isEmpty {
|
||||||
|
HStack {
|
||||||
|
controlsHeader("Audio Track".localized())
|
||||||
|
Spacer()
|
||||||
|
audioTrackButton
|
||||||
|
#if os(tvOS)
|
||||||
|
.focused($focusedField, equals: .audioTrack)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
controlsHeader("Backend".localized())
|
controlsHeader("Backend".localized())
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -453,6 +465,46 @@ struct PlaybackSettings: View {
|
|||||||
}
|
}
|
||||||
.disabled(captions.isEmpty)
|
.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 {
|
struct PlaybackSettings_Previews: PreviewProvider {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user