mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +00:00
Add embedded audio/subtitle track selection for multi-track files
Files with multiple embedded tracks (e.g. MKV from media sources or downloads) previously exposed none of them: mpv's track-list was never read, aid was never set, and sid was only ever no/auto. The quality selector showed no Audio or Subtitles tab for such files. MPVClient now observes track-list/aid/sid and delivers a coalesced, leniently decoded [MPVTrack] snapshot; selection state is derived from the reported "selected" flags. Embedded tracks are switched live via aid/sid (no reload), surfaced in the existing Audio/Subtitles sections alongside external captions on iOS, tvOS and macOS. External sub-add/ audio-add tracks are filtered out to avoid double-listing. User picks are sticky across same-video reloads (quality switch, audio mode, retries) and preferred audio/subtitle languages auto-select a matching embedded track once per load, with external captions taking precedence.
This commit is contained in:
@@ -7886,6 +7886,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.track.default" : {
|
||||
"comment" : "Badge for the container's default embedded track",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "DEFAULT"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.track.forced" : {
|
||||
"comment" : "Badge for forced embedded subtitle tracks",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "FORCED"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.track.number %lld" : {
|
||||
"comment" : "Fallback name for an embedded track without title or language",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Track %lld"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.tvos.volumeDown" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
|
||||
150
Yattee/Models/MPVTrack.swift
Normal file
150
Yattee/Models/MPVTrack.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// MPVTrack.swift
|
||||
// Yattee
|
||||
//
|
||||
// Model representing one entry of mpv's `track-list` property.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A single track reported by mpv's `track-list` property (embedded or external).
|
||||
struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
|
||||
enum TrackType: String, Decodable, Sendable {
|
||||
case video
|
||||
case audio
|
||||
case sub
|
||||
}
|
||||
|
||||
/// mpv track id — unique only within a track type.
|
||||
let trackID: Int
|
||||
let type: TrackType
|
||||
let title: String?
|
||||
let lang: String?
|
||||
let isDefault: Bool
|
||||
let isForced: Bool
|
||||
/// True for tracks loaded via `sub-add`/`audio-add` (external files).
|
||||
let isExternal: Bool
|
||||
/// Whether mpv currently plays this track.
|
||||
let isSelected: Bool
|
||||
/// Cover-art pseudo video tracks.
|
||||
let isAlbumArt: Bool
|
||||
let codec: String?
|
||||
let channelCount: Int?
|
||||
let sampleRate: Int?
|
||||
|
||||
/// Identifiable across types — mpv ids collide between audio/sub/video.
|
||||
var id: String { "\(type.rawValue):\(trackID)" }
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case trackID = "id"
|
||||
case type
|
||||
case title
|
||||
case lang
|
||||
case isDefault = "default"
|
||||
case isForced = "forced"
|
||||
case isExternal = "external"
|
||||
case isSelected = "selected"
|
||||
case isAlbumArt = "albumart"
|
||||
case codec
|
||||
case channelCount = "demux-channel-count"
|
||||
case sampleRate = "demux-samplerate"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
trackID = try container.decode(Int.self, forKey: .trackID)
|
||||
type = try container.decode(TrackType.self, forKey: .type)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
lang = try container.decodeIfPresent(String.self, forKey: .lang)
|
||||
isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false
|
||||
isForced = try container.decodeIfPresent(Bool.self, forKey: .isForced) ?? false
|
||||
isExternal = try container.decodeIfPresent(Bool.self, forKey: .isExternal) ?? false
|
||||
isSelected = try container.decodeIfPresent(Bool.self, forKey: .isSelected) ?? false
|
||||
isAlbumArt = try container.decodeIfPresent(Bool.self, forKey: .isAlbumArt) ?? false
|
||||
codec = try container.decodeIfPresent(String.self, forKey: .codec)
|
||||
channelCount = try container.decodeIfPresent(Int.self, forKey: .channelCount)
|
||||
sampleRate = try container.decodeIfPresent(Int.self, forKey: .sampleRate)
|
||||
}
|
||||
|
||||
init(
|
||||
trackID: Int,
|
||||
type: TrackType,
|
||||
title: String? = nil,
|
||||
lang: String? = nil,
|
||||
isDefault: Bool = false,
|
||||
isForced: Bool = false,
|
||||
isExternal: Bool = false,
|
||||
isSelected: Bool = false,
|
||||
isAlbumArt: Bool = false,
|
||||
codec: String? = nil,
|
||||
channelCount: Int? = nil,
|
||||
sampleRate: Int? = nil
|
||||
) {
|
||||
self.trackID = trackID
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.lang = lang
|
||||
self.isDefault = isDefault
|
||||
self.isForced = isForced
|
||||
self.isExternal = isExternal
|
||||
self.isSelected = isSelected
|
||||
self.isAlbumArt = isAlbumArt
|
||||
self.codec = codec
|
||||
self.channelCount = channelCount
|
||||
self.sampleRate = sampleRate
|
||||
}
|
||||
|
||||
/// Base language code normalized to the 2-letter form when possible, so
|
||||
/// Matroska 3-letter codes ("eng") match preference values ("en").
|
||||
var baseLanguageCode: String? {
|
||||
guard let lang, !lang.isEmpty, lang != "und" else { return nil }
|
||||
var code = lang.lowercased()
|
||||
if let hyphenIndex = code.firstIndex(of: "-") {
|
||||
code = String(code[..<hyphenIndex])
|
||||
}
|
||||
guard code.count == 3 else { return code }
|
||||
// ISO 639-2 -> 639-1 where a 2-letter code exists ("eng" -> "en").
|
||||
return Locale.LanguageCode(code).identifier(.alpha2) ?? code
|
||||
}
|
||||
|
||||
/// "Director's Commentary", "English", or a numbered fallback.
|
||||
var displayName: String {
|
||||
if let title, !title.isEmpty {
|
||||
return title
|
||||
}
|
||||
if let baseLanguageCode,
|
||||
let localized = Locale.current.localizedString(forLanguageCode: baseLanguageCode) {
|
||||
return localized
|
||||
}
|
||||
return String(localized: "player.track.number \(trackID)")
|
||||
}
|
||||
|
||||
/// Whether this track's language matches a user preference code like "en".
|
||||
func matchesLanguage(_ preferredCode: String?) -> Bool {
|
||||
guard let preferredCode, !preferredCode.isEmpty,
|
||||
let baseLanguageCode else { return false }
|
||||
var preferred = preferredCode.lowercased()
|
||||
if let hyphenIndex = preferred.firstIndex(of: "-") {
|
||||
preferred = String(preferred[..<hyphenIndex])
|
||||
}
|
||||
if preferred.count == 3 {
|
||||
preferred = Locale.LanguageCode(preferred).identifier(.alpha2) ?? preferred
|
||||
}
|
||||
return preferred == baseLanguageCode
|
||||
}
|
||||
|
||||
/// Secondary detail line for advanced mode, e.g. "eac3 · 6ch · 48 kHz".
|
||||
var detailText: String? {
|
||||
var parts: [String] = []
|
||||
if let codec, !codec.isEmpty {
|
||||
parts.append(codec)
|
||||
}
|
||||
if let channelCount {
|
||||
parts.append("\(channelCount)ch")
|
||||
}
|
||||
if let sampleRate {
|
||||
parts.append("\(sampleRate / 1000) kHz")
|
||||
}
|
||||
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ protocol MPVClientDelegate: AnyObject {
|
||||
func mpvClient(_ client: MPVClient, didUpdateProperty property: String, value: Any?)
|
||||
func mpvClient(_ client: MPVClient, didReceiveEvent event: mpv_event_id)
|
||||
func mpvClient(_ client: MPVClient, didUpdateCacheState cacheState: MPVCacheState)
|
||||
func mpvClient(_ client: MPVClient, didUpdateTrackList tracks: [MPVTrack])
|
||||
func mpvClientDidEndFile(_ client: MPVClient, reason: MPVEndFileReason, errorCode: Int32, errorString: String?)
|
||||
}
|
||||
|
||||
@@ -166,6 +167,11 @@ final class MPVClient: @unchecked Sendable {
|
||||
/// The loop itself exits via `isDestroyed` + `mpv_wakeup` + `eventLoopExitSemaphore`.
|
||||
private var eventLoopRunning = false
|
||||
|
||||
/// Whether a coalesced track-list refresh is pending (mpvQueue-guarded).
|
||||
/// Load and sub-add fire bursts of track-list/aid/sid notifications;
|
||||
/// they collapse into a single fetch.
|
||||
private var trackListRefreshScheduled = false
|
||||
|
||||
/// Semaphore signaled when event loop exits
|
||||
private let eventLoopExitSemaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
@@ -648,6 +654,11 @@ final class MPVClient: @unchecked Sendable {
|
||||
observeProperty("hwdec-interop", format: MPV_FORMAT_STRING)
|
||||
// Color transfer for tvOS dynamic-range matching (SDR / HDR10 / HLG)
|
||||
observeProperty("video-params/gamma", format: MPV_FORMAT_STRING)
|
||||
// Track roster and selection: change signals only — the values are
|
||||
// ignored and a coalesced track-list fetch runs instead.
|
||||
observeProperty("track-list", format: MPV_FORMAT_NONE)
|
||||
observeProperty("aid", format: MPV_FORMAT_STRING)
|
||||
observeProperty("sid", format: MPV_FORMAT_STRING)
|
||||
}
|
||||
|
||||
// MARK: - Options
|
||||
@@ -840,6 +851,66 @@ final class MPVClient: @unchecked Sendable {
|
||||
commandAsync(["sub-remove"])
|
||||
}
|
||||
|
||||
// MARK: - Embedded Tracks
|
||||
|
||||
/// Select an audio track by mpv track id (live, no reload). nil disables audio.
|
||||
func setAudioTrack(_ trackID: Int?) {
|
||||
setProperty("aid", trackID.map(String.init) ?? "no")
|
||||
}
|
||||
|
||||
/// Select a subtitle track by mpv track id (embedded or external). nil disables subtitles.
|
||||
func setSubtitleTrack(_ trackID: Int?) {
|
||||
setProperty("sid", trackID.map(String.init) ?? "no")
|
||||
}
|
||||
|
||||
/// Schedule a coalesced fetch of `track-list`, delivered via
|
||||
/// `mpvClient(_:didUpdateTrackList:)`. Safe to call from any thread.
|
||||
private func scheduleTrackListRefresh() {
|
||||
mpvQueue.async { [weak self] in
|
||||
guard let self, !self.trackListRefreshScheduled, !self.isDestroyed else { return }
|
||||
self.trackListRefreshScheduled = true
|
||||
self.mpvQueue.asyncAfter(deadline: .now() + 0.08) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.trackListRefreshScheduled = false
|
||||
self.refreshTrackListUnsafe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read and deliver the current track list (must be called on mpvQueue).
|
||||
private func refreshTrackListUnsafe() {
|
||||
guard let mpv, !isDestroyed else { return }
|
||||
guard let cString = mpv_get_property_string(mpv, "track-list") else { return }
|
||||
let json = String(cString: cString)
|
||||
mpv_free(cString)
|
||||
|
||||
let tracks = Self.parseTrackList(json: json)
|
||||
if tracks.isEmpty, json.count > 2 {
|
||||
logWarning("Failed to parse track-list", details: String(json.prefix(500)))
|
||||
}
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.mpvClient(self, didUpdateTrackList: tracks)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode mpv's JSON representation of `track-list`. Lenient: a single
|
||||
/// undecodable entry is dropped instead of blanking the whole list.
|
||||
static func parseTrackList(json: String) -> [MPVTrack] {
|
||||
struct FailableTrack: Decodable {
|
||||
let track: MPVTrack?
|
||||
init(from decoder: Decoder) {
|
||||
track = try? MPVTrack(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
guard let data = json.data(using: .utf8),
|
||||
let entries = try? JSONDecoder().decode([FailableTrack].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return entries.compactMap(\.track)
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// ⚠️ **THREADING WARNING FOR SYNC GETTERS**: The synchronous property getters below
|
||||
@@ -1663,6 +1734,14 @@ final class MPVClient: @unchecked Sendable {
|
||||
|
||||
private func handlePropertyChange(_ property: mpv_event_property) {
|
||||
let name = String(cString: property.name)
|
||||
|
||||
// Any change to the track roster or selection funnels into one
|
||||
// coalesced track-list refresh; the raw property values are unused.
|
||||
if name == "track-list" || name == "aid" || name == "sid" {
|
||||
scheduleTrackListRefresh()
|
||||
return
|
||||
}
|
||||
|
||||
var value: Any?
|
||||
|
||||
switch property.format {
|
||||
|
||||
@@ -99,6 +99,9 @@ final class MPVBackend: PlayerBackend {
|
||||
private var currentCaption: Caption?
|
||||
private var pendingAutoplay: Bool = false
|
||||
|
||||
/// Tracks reported by mpv for the loaded file (embedded and external).
|
||||
private(set) var currentTracks: [MPVTrack] = []
|
||||
|
||||
// Retry mechanism: 4 attempts with increasing timeouts and delays
|
||||
// Attempt 1: 3s timeout, 1s delay | Attempt 2: 3s timeout, 3s delay
|
||||
// Attempt 3: 10s timeout, 5s delay | Attempt 4: 10s timeout, fail
|
||||
@@ -481,6 +484,10 @@ final class MPVBackend: PlayerBackend {
|
||||
currentCaption = nil
|
||||
mpvClient?.removeAllSubtitlesAsync()
|
||||
|
||||
// Clear stale track info; the observer repopulates it once the new file loads
|
||||
currentTracks = []
|
||||
delegate?.backend(self, didUpdateTracks: [])
|
||||
|
||||
// Reset first-frame tracking for new content
|
||||
renderView?.resetFirstFrameTracking()
|
||||
applyStreamFrameRateHint(stream)
|
||||
@@ -910,6 +917,22 @@ final class MPVBackend: PlayerBackend {
|
||||
currentCaption
|
||||
}
|
||||
|
||||
// MARK: - Embedded Tracks
|
||||
|
||||
/// Select an embedded audio track by mpv track id (live, no reload).
|
||||
func selectEmbeddedAudioTrack(_ trackID: Int) {
|
||||
mpvClient?.setAudioTrack(trackID)
|
||||
}
|
||||
|
||||
/// Select an embedded subtitle track by mpv track id (nil = off).
|
||||
/// Deliberately no `sub-remove`: external captions stay loaded so the user
|
||||
/// can switch back without a re-download. Selection is no longer
|
||||
/// represented by a Caption, so `currentCaption` is cleared.
|
||||
func selectEmbeddedSubtitleTrack(_ trackID: Int?) {
|
||||
currentCaption = nil
|
||||
mpvClient?.setSubtitleTrack(trackID)
|
||||
}
|
||||
|
||||
/// Update subtitle appearance settings on the active MPV instance.
|
||||
/// Call this after changing subtitle settings to apply them immediately without restarting playback.
|
||||
func updateSubtitleSettings() {
|
||||
@@ -1573,6 +1596,14 @@ extension MPVBackend: MPVClientDelegate {
|
||||
// Cache state is used for buffer display on seek bar - no action needed here
|
||||
}
|
||||
|
||||
nonisolated func mpvClient(_ client: MPVClient, didUpdateTrackList tracks: [MPVTrack]) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.currentTracks = tracks
|
||||
self.delegate?.backend(self, didUpdateTracks: tracks)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func mpvClientDidEndFile(_ client: MPVClient, reason: MPVEndFileReason, errorCode: Int32, errorString: String?) {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleEndFile(reason: reason, errorCode: errorCode, errorString: errorString)
|
||||
|
||||
@@ -152,6 +152,7 @@ protocol PlayerBackendDelegate: AnyObject {
|
||||
func backend(_ backend: any PlayerBackend, didUpdateVideoSize width: Int, height: Int)
|
||||
func backend(_ backend: any PlayerBackend, didUpdateRetryState currentRetry: Int, maxRetries: Int, isRetrying: Bool, exhausted: Bool)
|
||||
func backend(_ backend: any PlayerBackend, didRequestStreamRefresh atTime: TimeInterval?)
|
||||
func backend(_ backend: any PlayerBackend, didUpdateTracks tracks: [MPVTrack])
|
||||
func backendDidBecomeReady(_ backend: any PlayerBackend)
|
||||
func backendDidFinishPlaying(_ backend: any PlayerBackend)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,37 @@ final class PlayerService {
|
||||
/// Currently loaded caption.
|
||||
private(set) var currentCaption: Caption?
|
||||
|
||||
/// Tracks reported by mpv for the currently loaded file (embedded and external).
|
||||
private(set) var embeddedTracks: [MPVTrack] = []
|
||||
|
||||
/// Embedded (in-container) audio tracks of the current file.
|
||||
var embeddedAudioTracks: [MPVTrack] {
|
||||
embeddedTracks.filter { $0.type == .audio && !$0.isExternal }
|
||||
}
|
||||
|
||||
/// Embedded (in-container) subtitle tracks of the current file.
|
||||
var embeddedSubtitleTracks: [MPVTrack] {
|
||||
embeddedTracks.filter { $0.type == .sub && !$0.isExternal }
|
||||
}
|
||||
|
||||
/// The embedded audio track mpv currently plays, if any.
|
||||
var selectedEmbeddedAudioTrackID: Int? {
|
||||
embeddedAudioTracks.first(where: \.isSelected)?.trackID
|
||||
}
|
||||
|
||||
/// The embedded subtitle track mpv currently shows, if any.
|
||||
var selectedEmbeddedSubtitleTrackID: Int? {
|
||||
embeddedSubtitleTracks.first(where: \.isSelected)?.trackID
|
||||
}
|
||||
|
||||
/// Embedded-track picks that should survive same-video reloads
|
||||
/// (quality switch, audio-mode toggle, buffer-stall retry).
|
||||
private var desiredEmbeddedAudioTrackID: Int?
|
||||
private var desiredEmbeddedSubtitleTrackID: Int?
|
||||
|
||||
/// Whether preferred-language auto-selection already ran for this load.
|
||||
private var didAutoSelectEmbeddedTracks = false
|
||||
|
||||
/// The current download being played, if any.
|
||||
private(set) var currentDownload: Download?
|
||||
|
||||
@@ -234,6 +265,15 @@ final class PlayerService {
|
||||
availableStreams = []
|
||||
}
|
||||
|
||||
// Embedded-track state: the mpv-reported list always refreshes on load,
|
||||
// but sticky user picks only reset when the video actually changes.
|
||||
embeddedTracks = []
|
||||
didAutoSelectEmbeddedTracks = false
|
||||
if isNewVideo {
|
||||
desiredEmbeddedAudioTrackID = nil
|
||||
desiredEmbeddedSubtitleTrackID = nil
|
||||
}
|
||||
|
||||
// Clear sponsor block state from previous video
|
||||
state.sponsorSegments = []
|
||||
state.currentSegment = nil
|
||||
@@ -619,6 +659,10 @@ final class PlayerService {
|
||||
preDownloadedSubtitleFolders.removeAll()
|
||||
cleanupAllTempSubtitles()
|
||||
currentCaption = nil
|
||||
embeddedTracks = []
|
||||
desiredEmbeddedAudioTrackID = nil
|
||||
desiredEmbeddedSubtitleTrackID = nil
|
||||
didAutoSelectEmbeddedTracks = false
|
||||
lastSkippedSegmentID = nil
|
||||
nowPlayingService.clearNowPlaying()
|
||||
}
|
||||
@@ -1303,6 +1347,8 @@ final class PlayerService {
|
||||
|
||||
mpvBackend.loadCaption(caption)
|
||||
currentCaption = caption
|
||||
// An explicit external pick (or Off) overrides any embedded-subtitle intent
|
||||
desiredEmbeddedSubtitleTrackID = nil
|
||||
|
||||
if let caption {
|
||||
LoggingService.shared.logPlayer("Loaded caption: \(caption.displayName)")
|
||||
@@ -1311,6 +1357,70 @@ final class PlayerService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects an embedded audio track by mpv track id (live, no reload).
|
||||
/// Only works with MPV backend.
|
||||
func selectEmbeddedAudioTrack(_ trackID: Int) {
|
||||
guard let mpvBackend = currentBackend as? MPVBackend else { return }
|
||||
|
||||
desiredEmbeddedAudioTrackID = trackID
|
||||
mpvBackend.selectEmbeddedAudioTrack(trackID)
|
||||
LoggingService.shared.logPlayer("Selected embedded audio track \(trackID)")
|
||||
}
|
||||
|
||||
/// Selects an embedded subtitle track by mpv track id (nil = off).
|
||||
/// Only works with MPV backend.
|
||||
func selectEmbeddedSubtitleTrack(_ trackID: Int?) {
|
||||
guard let mpvBackend = currentBackend as? MPVBackend else { return }
|
||||
|
||||
desiredEmbeddedSubtitleTrackID = trackID
|
||||
currentCaption = nil
|
||||
mpvBackend.selectEmbeddedSubtitleTrack(trackID)
|
||||
LoggingService.shared.logPlayer("Selected embedded subtitle track \(trackID.map(String.init) ?? "off")")
|
||||
}
|
||||
|
||||
/// Re-applies a sticky embedded-track pick after a reload, or auto-selects
|
||||
/// preferred languages once per load. Called whenever mpv's track list changes.
|
||||
private func reapplyOrAutoSelectEmbeddedTracks() {
|
||||
guard let mpvBackend = currentBackend as? MPVBackend else { return }
|
||||
|
||||
// Re-apply sticky user picks. Loop-safe: selecting fires one more
|
||||
// track-list update in which the track is already selected.
|
||||
if let desired = desiredEmbeddedAudioTrackID,
|
||||
let track = embeddedAudioTracks.first(where: { $0.trackID == desired }),
|
||||
!track.isSelected {
|
||||
mpvBackend.selectEmbeddedAudioTrack(desired)
|
||||
}
|
||||
if let desired = desiredEmbeddedSubtitleTrackID,
|
||||
let track = embeddedSubtitleTracks.first(where: { $0.trackID == desired }),
|
||||
!track.isSelected {
|
||||
mpvBackend.selectEmbeddedSubtitleTrack(desired)
|
||||
}
|
||||
|
||||
// Auto-select preferred languages once per load. Skipped while the
|
||||
// list is empty so the reset delivered at load start doesn't consume
|
||||
// the one-shot flag.
|
||||
guard !didAutoSelectEmbeddedTracks, !embeddedTracks.isEmpty else { return }
|
||||
didAutoSelectEmbeddedTracks = true
|
||||
|
||||
if desiredEmbeddedAudioTrackID == nil,
|
||||
embeddedAudioTracks.count > 1,
|
||||
let match = embeddedAudioTracks.first(where: { $0.matchesLanguage(settingsManager?.preferredAudioLanguage) }),
|
||||
!match.isSelected {
|
||||
mpvBackend.selectEmbeddedAudioTrack(match.trackID)
|
||||
LoggingService.shared.logPlayer("Auto-selected embedded audio track \(match.trackID) (\(match.displayName))")
|
||||
}
|
||||
|
||||
// External captions win: the preferred-caption flow sets currentCaption
|
||||
// before mpv reports any tracks.
|
||||
if desiredEmbeddedSubtitleTrackID == nil,
|
||||
currentCaption == nil,
|
||||
let match = embeddedSubtitleTracks.first(where: { !$0.isForced && $0.matchesLanguage(settingsManager?.preferredSubtitlesLanguage) }),
|
||||
!match.isSelected {
|
||||
mpvBackend.selectEmbeddedSubtitleTrack(match.trackID)
|
||||
LoggingService.shared.logPlayer("Auto-selected embedded subtitle track \(match.trackID) (\(match.displayName))")
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads a local subtitle file from disk.
|
||||
/// Only works with MPV backend.
|
||||
/// - Parameter url: The local file URL of the caption file
|
||||
@@ -2902,6 +3012,11 @@ extension PlayerService: PlayerBackendDelegate {
|
||||
state.setRetryState(retryState)
|
||||
}
|
||||
|
||||
func backend(_ backend: any PlayerBackend, didUpdateTracks tracks: [MPVTrack]) {
|
||||
embeddedTracks = tracks
|
||||
reapplyOrAutoSelectEmbeddedTracks()
|
||||
}
|
||||
|
||||
func backend(_ backend: any PlayerBackend, didRequestStreamRefresh atTime: TimeInterval?) {
|
||||
LoggingService.shared.logPlayer("Stream refresh requested at time: \(atTime ?? -1)")
|
||||
|
||||
|
||||
@@ -922,7 +922,8 @@ struct ControlsSectionRenderer: View {
|
||||
@ViewBuilder
|
||||
private var captionsButton: some View {
|
||||
if actions.hasCaptions, actions.onCaptionSelected != nil {
|
||||
controlButton(systemImage: actions.currentCaption != nil ? "captions.bubble.fill" : "captions.bubble") {
|
||||
let subtitlesActive = actions.currentCaption != nil || actions.currentEmbeddedSubtitleTrackID != nil
|
||||
controlButton(systemImage: subtitlesActive ? "captions.bubble.fill" : "captions.bubble") {
|
||||
actions.onShowCaptionsSelector?()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -862,6 +862,16 @@ extension ExpandedPlayerSheet {
|
||||
onStreamSelected: { [self] stream, audioStream in
|
||||
switchToStream(stream, audioStream: audioStream)
|
||||
},
|
||||
embeddedAudioTracks: playerService.embeddedAudioTracks,
|
||||
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
|
||||
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
|
||||
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
|
||||
onEmbeddedAudioTrackSelected: { trackID in
|
||||
playerService.selectEmbeddedAudioTrack(trackID)
|
||||
},
|
||||
onEmbeddedSubtitleTrackSelected: { trackID in
|
||||
playerService.selectEmbeddedSubtitleTrack(trackID)
|
||||
},
|
||||
panscanValue: navigationCoordinator?.pinchPanscan ?? 0.0,
|
||||
isPanscanAllowed: !isPortraitPanelVisible,
|
||||
onTogglePanscan: { [weak navigationCoordinator] in
|
||||
@@ -1132,6 +1142,16 @@ extension ExpandedPlayerSheet {
|
||||
onStreamSelected: { [self] stream, audioStream in
|
||||
switchToStream(stream, audioStream: audioStream)
|
||||
},
|
||||
embeddedAudioTracks: playerService.embeddedAudioTracks,
|
||||
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
|
||||
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
|
||||
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
|
||||
onEmbeddedAudioTrackSelected: { trackID in
|
||||
playerService.selectEmbeddedAudioTrack(trackID)
|
||||
},
|
||||
onEmbeddedSubtitleTrackSelected: { trackID in
|
||||
playerService.selectEmbeddedSubtitleTrack(trackID)
|
||||
},
|
||||
panscanValue: navigationCoordinator?.pinchPanscan ?? 0.0,
|
||||
isPanscanAllowed: !isPortraitPanelVisible,
|
||||
onTogglePanscan: { [weak navigationCoordinator] in
|
||||
@@ -1509,6 +1529,16 @@ extension ExpandedPlayerSheet {
|
||||
onStreamSelected: { [self] stream, audioStream in
|
||||
switchToStream(stream, audioStream: audioStream)
|
||||
},
|
||||
embeddedAudioTracks: playerService.embeddedAudioTracks,
|
||||
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
|
||||
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
|
||||
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
|
||||
onEmbeddedAudioTrackSelected: { trackID in
|
||||
playerService.selectEmbeddedAudioTrack(trackID)
|
||||
},
|
||||
onEmbeddedSubtitleTrackSelected: { trackID in
|
||||
playerService.selectEmbeddedSubtitleTrack(trackID)
|
||||
},
|
||||
panscanValue: navigationCoordinator?.pinchPanscan ?? 0.0,
|
||||
isPanscanAllowed: !(isPanelPinned && isPanelVisible),
|
||||
onTogglePanscan: { [weak navigationCoordinator] in
|
||||
|
||||
@@ -697,6 +697,10 @@ private struct PlayerSheetsModifier: ViewModifier {
|
||||
currentRate: playerState?.rate ?? .x1,
|
||||
isControlsLocked: playerState?.isControlsLocked ?? false,
|
||||
isAudioMode: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false,
|
||||
embeddedAudioTracks: playerService.embeddedAudioTracks,
|
||||
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
|
||||
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
|
||||
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
|
||||
onStreamSelected: { stream, audioStream in
|
||||
onStreamSelected(stream, audioStream)
|
||||
},
|
||||
@@ -713,6 +717,12 @@ private struct PlayerSheetsModifier: ViewModifier {
|
||||
await playerService.switchToOnlineStream(stream, audioStream: audioStream)
|
||||
}
|
||||
},
|
||||
onEmbeddedAudioTrackSelected: { trackID in
|
||||
playerService.selectEmbeddedAudioTrack(trackID)
|
||||
},
|
||||
onEmbeddedSubtitleTrackSelected: { trackID in
|
||||
playerService.selectEmbeddedSubtitleTrack(trackID)
|
||||
},
|
||||
onRateChanged: { rate in
|
||||
playerState?.rate = rate
|
||||
playerService.currentBackend?.rate = Float(rate.rawValue)
|
||||
|
||||
@@ -261,6 +261,16 @@ struct MPVVideoView: View {
|
||||
Task {
|
||||
await playerService.selectStreamManually(stream, audioStream: audioStream)
|
||||
}
|
||||
},
|
||||
embeddedAudioTracks: playerService.embeddedAudioTracks,
|
||||
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
|
||||
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
|
||||
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
|
||||
onEmbeddedAudioTrackSelected: { trackID in
|
||||
playerService.selectEmbeddedAudioTrack(trackID)
|
||||
},
|
||||
onEmbeddedSubtitleTrackSelected: { trackID in
|
||||
playerService.selectEmbeddedSubtitleTrack(trackID)
|
||||
}
|
||||
)
|
||||
#elseif os(macOS)
|
||||
|
||||
@@ -76,6 +76,12 @@ struct PlayerControlsActions {
|
||||
/// Current audio stream
|
||||
let currentAudioStream: Stream?
|
||||
|
||||
/// Embedded (in-container) tracks reported by mpv for the loaded file
|
||||
var embeddedAudioTracks: [MPVTrack] = []
|
||||
var embeddedSubtitleTracks: [MPVTrack] = []
|
||||
var currentEmbeddedAudioTrackID: Int? = nil
|
||||
var currentEmbeddedSubtitleTrackID: Int? = nil
|
||||
|
||||
#if os(iOS)
|
||||
/// Current panscan value (0.0 = fit, 1.0 = fill)
|
||||
let panscanValue: Double
|
||||
@@ -217,9 +223,9 @@ struct PlayerControlsActions {
|
||||
return !video.isFromLocalFolder
|
||||
}
|
||||
|
||||
/// Whether captions are available
|
||||
/// Whether captions are available (external files or embedded tracks)
|
||||
var hasCaptions: Bool {
|
||||
!availableCaptions.isEmpty
|
||||
!availableCaptions.isEmpty || !embeddedSubtitleTracks.isEmpty
|
||||
}
|
||||
|
||||
/// Whether chapters are available
|
||||
|
||||
@@ -91,6 +91,15 @@ struct PlayerControlsView: View {
|
||||
var onCaptionSelected: ((Caption?) -> Void)? = nil
|
||||
/// Callback when stream is selected (for quality selector integration)
|
||||
var onStreamSelected: ((Stream, Stream?) -> Void)? = nil
|
||||
/// Embedded (in-container) tracks reported by mpv for the loaded file
|
||||
var embeddedAudioTracks: [MPVTrack] = []
|
||||
var embeddedSubtitleTracks: [MPVTrack] = []
|
||||
var currentEmbeddedAudioTrackID: Int? = nil
|
||||
var currentEmbeddedSubtitleTrackID: Int? = nil
|
||||
/// Callback when an embedded audio track is selected
|
||||
var onEmbeddedAudioTrackSelected: ((Int) -> Void)? = nil
|
||||
/// Callback when an embedded subtitle track is selected (nil = off)
|
||||
var onEmbeddedSubtitleTrackSelected: ((Int?) -> Void)? = nil
|
||||
|
||||
/// Current panscan value (0.0 = fit, 1.0 = fill)
|
||||
var panscanValue: Double = 0.0
|
||||
@@ -285,9 +294,14 @@ struct PlayerControlsView: View {
|
||||
currentCaption: currentCaption,
|
||||
initialTab: .subtitles,
|
||||
showTabPicker: false,
|
||||
embeddedSubtitleTracks: embeddedSubtitleTracks,
|
||||
currentEmbeddedSubtitleTrackID: currentEmbeddedSubtitleTrackID,
|
||||
onStreamSelected: { _, _ in },
|
||||
onCaptionSelected: { caption in
|
||||
onCaptionSelected?(caption)
|
||||
},
|
||||
onEmbeddedSubtitleTrackSelected: { trackID in
|
||||
onEmbeddedSubtitleTrackSelected?(trackID)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -317,10 +331,15 @@ struct PlayerControlsView: View {
|
||||
currentAudioStream: currentAudioStream,
|
||||
initialTab: .audio,
|
||||
showTabPicker: false,
|
||||
embeddedAudioTracks: embeddedAudioTracks,
|
||||
currentEmbeddedAudioTrackID: currentEmbeddedAudioTrackID,
|
||||
onStreamSelected: { stream, audioStream in
|
||||
onStreamSelected?(stream, audioStream)
|
||||
},
|
||||
onCaptionSelected: { _ in }
|
||||
onCaptionSelected: { _ in },
|
||||
onEmbeddedAudioTrackSelected: { trackID in
|
||||
onEmbeddedAudioTrackSelected?(trackID)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -379,6 +398,10 @@ struct PlayerControlsView: View {
|
||||
availableStreams: availableStreams,
|
||||
currentStream: currentStream,
|
||||
currentAudioStream: currentAudioStream,
|
||||
embeddedAudioTracks: embeddedAudioTracks,
|
||||
embeddedSubtitleTracks: embeddedSubtitleTracks,
|
||||
currentEmbeddedAudioTrackID: currentEmbeddedAudioTrackID,
|
||||
currentEmbeddedSubtitleTrackID: currentEmbeddedSubtitleTrackID,
|
||||
panscanValue: panscanValue,
|
||||
isPanscanAllowed: isPanscanAllowed,
|
||||
isAutoPlayNextEnabled: appEnvironment?.settingsManager.queueAutoPlayNext ?? true,
|
||||
|
||||
@@ -414,6 +414,83 @@ struct CaptionRowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Embedded Track Row
|
||||
|
||||
/// Row view for embedded (in-container) audio/subtitle tracks reported by mpv.
|
||||
struct EmbeddedTrackRowView: View {
|
||||
let track: MPVTrack
|
||||
let isSelected: Bool
|
||||
let isPreferred: Bool
|
||||
let showAdvancedDetails: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
titleRow
|
||||
if showAdvancedDetails, let detail = track.detailText {
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: showAdvancedDetails ? nil : 36)
|
||||
#if os(tvOS)
|
||||
.padding(.vertical, tvRowVerticalPadding)
|
||||
.padding(.horizontal, tvRowHorizontalPadding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(TVSettingsRowButtonStyle())
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var titleRow: some View {
|
||||
HStack(spacing: 6) {
|
||||
if isPreferred {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
Text(track.displayName)
|
||||
.font(.headline)
|
||||
|
||||
if track.isDefault {
|
||||
badge(String(localized: "player.track.default"))
|
||||
}
|
||||
|
||||
if track.isForced {
|
||||
badge(String(localized: "player.track.forced"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func badge(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.gray.opacity(0.2))
|
||||
.foregroundStyle(.secondary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Adaptive Stream Row") {
|
||||
|
||||
@@ -188,6 +188,11 @@ extension QualitySelectorView {
|
||||
}
|
||||
|
||||
private var currentAudioDisplayValue: String {
|
||||
if embeddedAudioTracks.count > 1,
|
||||
let trackID = currentEmbeddedAudioTrackID,
|
||||
let track = embeddedAudioTracks.first(where: { $0.trackID == trackID }) {
|
||||
return track.displayName
|
||||
}
|
||||
if isCurrentStreamMuxed {
|
||||
return String(localized: "player.quality.audioFromVideo.short")
|
||||
}
|
||||
@@ -198,7 +203,11 @@ extension QualitySelectorView {
|
||||
}
|
||||
|
||||
private var currentSubtitlesDisplayValue: String {
|
||||
currentCaption?.displayName ?? String(localized: "stream.subtitles.off")
|
||||
if let trackID = currentEmbeddedSubtitleTrackID,
|
||||
let track = embeddedSubtitleTracks.first(where: { $0.trackID == trackID }) {
|
||||
return track.displayName
|
||||
}
|
||||
return currentCaption?.displayName ?? String(localized: "stream.subtitles.off")
|
||||
}
|
||||
|
||||
// MARK: - Detail Content Views
|
||||
@@ -741,7 +750,9 @@ extension QualitySelectorView {
|
||||
|
||||
@ViewBuilder
|
||||
var audioSectionContent: some View {
|
||||
if isCurrentStreamMuxed {
|
||||
if embeddedAudioTracks.count > 1 {
|
||||
embeddedAudioTracksContent
|
||||
} else if isCurrentStreamMuxed {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: "info.circle")
|
||||
@@ -778,6 +789,44 @@ extension QualitySelectorView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Embedded (in-container) audio tracks, switched live via mpv `aid`.
|
||||
@ViewBuilder
|
||||
private var embeddedAudioTracksContent: some View {
|
||||
#if os(tvOS)
|
||||
VStack(spacing: 8) {
|
||||
ForEach(embeddedAudioTracks) { track in
|
||||
embeddedAudioTrackRow(track)
|
||||
}
|
||||
}
|
||||
#else
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(embeddedAudioTracks.enumerated()), id: \.element.id) { index, track in
|
||||
if index > 0 {
|
||||
Divider()
|
||||
}
|
||||
embeddedAudioTrackRow(track)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
.cardBackground()
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func embeddedAudioTrackRow(_ track: MPVTrack) -> some View {
|
||||
EmbeddedTrackRowView(
|
||||
track: track,
|
||||
isSelected: track.trackID == currentEmbeddedAudioTrackID,
|
||||
isPreferred: track.matchesLanguage(preferredAudioLanguage),
|
||||
showAdvancedDetails: showAdvancedStreamDetails,
|
||||
onTap: {
|
||||
onEmbeddedAudioTrackSelected(track.trackID)
|
||||
performDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func audioStreamRow(_ stream: Stream) -> some View {
|
||||
let isSelected: Bool = stream.url == selectedAudioStream?.url
|
||||
@@ -816,13 +865,17 @@ extension QualitySelectorView {
|
||||
VStack(spacing: 8) {
|
||||
CaptionRowView(
|
||||
caption: nil,
|
||||
isSelected: currentCaption == nil,
|
||||
isSelected: currentCaption == nil && currentEmbeddedSubtitleTrackID == nil,
|
||||
isPreferred: false,
|
||||
onTap: {
|
||||
handleCaptionTap(nil)
|
||||
}
|
||||
)
|
||||
|
||||
ForEach(embeddedSubtitleTracks) { track in
|
||||
embeddedSubtitleTrackRow(track)
|
||||
}
|
||||
|
||||
ForEach(sortedCaptions) { caption in
|
||||
CaptionRowView(
|
||||
caption: caption,
|
||||
@@ -838,7 +891,7 @@ extension QualitySelectorView {
|
||||
VStack(spacing: 0) {
|
||||
CaptionRowView(
|
||||
caption: nil,
|
||||
isSelected: currentCaption == nil,
|
||||
isSelected: currentCaption == nil && currentEmbeddedSubtitleTrackID == nil,
|
||||
isPreferred: false,
|
||||
onTap: {
|
||||
handleCaptionTap(nil)
|
||||
@@ -847,6 +900,14 @@ extension QualitySelectorView {
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
ForEach(embeddedSubtitleTracks) { track in
|
||||
Divider()
|
||||
|
||||
embeddedSubtitleTrackRow(track)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
ForEach(sortedCaptions) { caption in
|
||||
Divider()
|
||||
|
||||
@@ -866,12 +927,34 @@ extension QualitySelectorView {
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func embeddedSubtitleTrackRow(_ track: MPVTrack) -> some View {
|
||||
EmbeddedTrackRowView(
|
||||
track: track,
|
||||
isSelected: track.trackID == currentEmbeddedSubtitleTrackID,
|
||||
isPreferred: track.matchesLanguage(preferredSubtitlesLanguage),
|
||||
showAdvancedDetails: showAdvancedStreamDetails,
|
||||
onTap: {
|
||||
// Re-tapping the selected track turns subtitles off (parity
|
||||
// with external caption rows)
|
||||
if track.trackID == currentEmbeddedSubtitleTrackID {
|
||||
onEmbeddedSubtitleTrackSelected(nil)
|
||||
} else {
|
||||
onEmbeddedSubtitleTrackSelected(track.trackID)
|
||||
}
|
||||
performDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func isCaptionPreferred(_ caption: Caption) -> Bool {
|
||||
guard let preferred = preferredSubtitlesLanguage else { return false }
|
||||
return caption.baseLanguageCode == preferred || caption.languageCode.hasPrefix(preferred)
|
||||
}
|
||||
|
||||
private func handleCaptionTap(_ caption: Caption?) {
|
||||
// Note: the Off row needs no embedded-track handling — caption "off"
|
||||
// sets sid=no and clears the embedded intent in PlayerService.
|
||||
if caption?.id == currentCaption?.id && caption != nil {
|
||||
onCaptionSelected(nil)
|
||||
} else {
|
||||
|
||||
@@ -29,6 +29,15 @@ struct QualitySelectorView: View {
|
||||
let onLoadOnlineStreams: () -> Void
|
||||
let onSwitchToOnlineStream: (Stream, Stream?) -> Void
|
||||
|
||||
/// Embedded (in-container) tracks reported by mpv for the loaded file.
|
||||
let embeddedAudioTracks: [MPVTrack]
|
||||
let embeddedSubtitleTracks: [MPVTrack]
|
||||
let currentEmbeddedAudioTrackID: Int?
|
||||
let currentEmbeddedSubtitleTrackID: Int?
|
||||
let onEmbeddedAudioTrackSelected: (Int) -> Void
|
||||
/// nil = subtitles off
|
||||
let onEmbeddedSubtitleTrackSelected: (Int?) -> Void
|
||||
|
||||
/// Current playback rate
|
||||
var currentRate: PlaybackRate = .x1
|
||||
/// Callback when playback rate changes
|
||||
@@ -100,10 +109,10 @@ struct QualitySelectorView: View {
|
||||
/// Available tabs based on streams
|
||||
var availableTabs: [QualitySelectorTab] {
|
||||
var tabs: [QualitySelectorTab] = [.video]
|
||||
if hasVideoOnlyStreams && !audioStreams.isEmpty {
|
||||
if (hasVideoOnlyStreams && !audioStreams.isEmpty) || embeddedAudioTracks.count > 1 {
|
||||
tabs.append(.audio)
|
||||
}
|
||||
if !captions.isEmpty {
|
||||
if !captions.isEmpty || !embeddedSubtitleTracks.isEmpty {
|
||||
tabs.append(.subtitles)
|
||||
}
|
||||
return tabs
|
||||
@@ -133,7 +142,7 @@ struct QualitySelectorView: View {
|
||||
/// Whether streams are empty (not loading, but no streams available)
|
||||
var hasNoStreams: Bool {
|
||||
if !showTabPicker && initialTab == .subtitles {
|
||||
return !isLoading && captions.isEmpty && !isPlayingDownloadedContent
|
||||
return !isLoading && captions.isEmpty && embeddedSubtitleTracks.isEmpty && !isPlayingDownloadedContent
|
||||
}
|
||||
return !isLoading && streams.isEmpty && !isPlayingDownloadedContent
|
||||
}
|
||||
@@ -168,10 +177,16 @@ struct QualitySelectorView: View {
|
||||
isAudioMode: Bool = false,
|
||||
initialTab: QualitySelectorTab = .video,
|
||||
showTabPicker: Bool = true,
|
||||
embeddedAudioTracks: [MPVTrack] = [],
|
||||
embeddedSubtitleTracks: [MPVTrack] = [],
|
||||
currentEmbeddedAudioTrackID: Int? = nil,
|
||||
currentEmbeddedSubtitleTrackID: Int? = nil,
|
||||
onStreamSelected: @escaping (Stream, Stream?) -> Void,
|
||||
onCaptionSelected: @escaping (Caption?) -> Void = { _ in },
|
||||
onLoadOnlineStreams: @escaping () -> Void = {},
|
||||
onSwitchToOnlineStream: @escaping (Stream, Stream?) -> Void = { _, _ in },
|
||||
onEmbeddedAudioTrackSelected: @escaping (Int) -> Void = { _ in },
|
||||
onEmbeddedSubtitleTrackSelected: @escaping (Int?) -> Void = { _ in },
|
||||
onRateChanged: ((PlaybackRate) -> Void)? = nil,
|
||||
onLockToggled: ((Bool) -> Void)? = nil,
|
||||
onAudioModeToggled: ((Bool) -> Void)? = nil,
|
||||
@@ -191,10 +206,16 @@ struct QualitySelectorView: View {
|
||||
self.currentRate = currentRate
|
||||
self.isControlsLocked = isControlsLocked
|
||||
self.isAudioMode = isAudioMode
|
||||
self.embeddedAudioTracks = embeddedAudioTracks
|
||||
self.embeddedSubtitleTracks = embeddedSubtitleTracks
|
||||
self.currentEmbeddedAudioTrackID = currentEmbeddedAudioTrackID
|
||||
self.currentEmbeddedSubtitleTrackID = currentEmbeddedSubtitleTrackID
|
||||
self.onStreamSelected = onStreamSelected
|
||||
self.onCaptionSelected = onCaptionSelected
|
||||
self.onLoadOnlineStreams = onLoadOnlineStreams
|
||||
self.onSwitchToOnlineStream = onSwitchToOnlineStream
|
||||
self.onEmbeddedAudioTrackSelected = onEmbeddedAudioTrackSelected
|
||||
self.onEmbeddedSubtitleTrackSelected = onEmbeddedSubtitleTrackSelected
|
||||
self.onRateChanged = onRateChanged
|
||||
self.onLockToggled = onLockToggled
|
||||
self.onAudioModeToggled = onAudioModeToggled
|
||||
|
||||
@@ -167,6 +167,10 @@ struct TVPlayerView: View {
|
||||
},
|
||||
currentRate: playerState?.rate ?? .x1,
|
||||
isAudioMode: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false,
|
||||
embeddedAudioTracks: playerService.embeddedAudioTracks,
|
||||
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
|
||||
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
|
||||
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
|
||||
onStreamSelected: { stream, audioStream in
|
||||
switchToStream(stream, audioStream: audioStream)
|
||||
},
|
||||
@@ -183,6 +187,12 @@ struct TVPlayerView: View {
|
||||
await playerService.switchToOnlineStream(stream, audioStream: audioStream)
|
||||
}
|
||||
},
|
||||
onEmbeddedAudioTrackSelected: { trackID in
|
||||
playerService.selectEmbeddedAudioTrack(trackID)
|
||||
},
|
||||
onEmbeddedSubtitleTrackSelected: { trackID in
|
||||
playerService.selectEmbeddedSubtitleTrack(trackID)
|
||||
},
|
||||
onRateChanged: { rate in
|
||||
playerState?.rate = rate
|
||||
playerService.currentBackend?.rate = Float(rate.rawValue)
|
||||
|
||||
Reference in New Issue
Block a user