Apply audio mode to downloaded videos

Downloads bypass stream selection and reach play() as ready-made local
file streams, so audio mode never affected them. Apply the mode at that
choke point instead: swap a local video stream for the separately
downloaded audio track, or play a muxed file with the video track
disabled (vid set per-load in MPVClient, never toggled live). The
transform is bidirectional so stored audio-only queue/history entries
restore full video when the mode is off, and setAudioMode now reloads
downloaded content at the current position in both directions.
This commit is contained in:
Arkadiusz Fal
2026-07-17 09:27:09 +02:00
parent 6514cb426e
commit c2554cee04
4 changed files with 111 additions and 7 deletions

View File

@@ -189,6 +189,38 @@ struct StreamResolution: Codable, Hashable, Sendable, Comparable, CustomStringCo
}
}
// MARK: - Audio-Only Variant
extension Stream {
/// Whether the backend must disable the video track when loading this stream:
/// a muxed file being played as audio-only (see `audioOnlyVariant()`).
var requiresVideoTrackDisabled: Bool {
isAudioOnly && videoCodec != nil
}
/// Creates an audio-only copy of this stream keeping the same URL.
/// Used for muxed local files in audio mode: `videoCodec` stays set so
/// `requiresVideoTrackDisabled` tells the backend to skip the video track.
func audioOnlyVariant() -> Stream {
Stream(
url: url,
resolution: nil,
format: format,
videoCodec: videoCodec,
audioCodec: audioCodec,
bitrate: bitrate,
fileSize: fileSize,
isAudioOnly: true,
isLive: isLive,
mimeType: mimeType,
audioLanguage: audioLanguage,
audioTrackName: audioTrackName,
isOriginalAudio: isOriginalAudio,
httpHeaders: httpHeaders
)
}
}
// MARK: - URL Rewriting
extension Stream {

View File

@@ -621,13 +621,24 @@ final class MPVClient: @unchecked Sendable {
/// - audioURL: Optional separate audio track URL (for video-only streams)
/// - httpHeaders: Optional HTTP headers for streaming (cookies, referer, etc.)
/// - useEDL: If true and audioURL is provided, combine streams using EDL for unified caching
/// - disableVideoTrack: If true, load with the video track disabled (audio mode for muxed files)
/// - options: Additional MPV options
func loadFile(_ url: URL, audioURL: URL? = nil, httpHeaders: [String: String]? = nil, useEDL: Bool = true, options: [String] = []) throws {
func loadFile(_ url: URL, audioURL: URL? = nil, httpHeaders: [String: String]? = nil, useEDL: Bool = true, disableVideoTrack: Bool = false, options: [String] = []) throws {
try mpvQueue.sync {
guard mpv != nil, !isDestroyed else {
throw MPVError.commandFailed("loadfile")
}
// Video track selection for the upcoming file. Set as a property
// before the loadfile command (per-file loadfile options moved to
// the 4th argument in mpv 0.38+, so they can't be relied on here).
// Only ever changed between loads, never live - live `vid` toggling
// causes A/V desync (see handlePlayerSheetVisibility).
setPropertyUnsafe("vid", disableVideoTrack ? "no" : "auto")
if disableVideoTrack {
logDebug("Video track disabled for this load (audio mode)")
}
// Set HTTP headers as a property before loading (if provided)
// Use MPV_FORMAT_NODE_ARRAY for proper header handling
if let httpHeaders, !httpHeaders.isEmpty {

View File

@@ -495,7 +495,13 @@ final class MPVBackend: PlayerBackend {
// Load the stream
do {
try mpvClient?.loadFile(stream.url, audioURL: audioStream?.url, httpHeaders: stream.httpHeaders, useEDL: useEDL)
try mpvClient?.loadFile(
stream.url,
audioURL: audioStream?.url,
httpHeaders: stream.httpHeaders,
useEDL: useEDL,
disableVideoTrack: stream.requiresVideoTrackDisabled
)
// Give MPV a moment to process the loadfile command
try await Task.sleep(for: .milliseconds(100))

View File

@@ -203,6 +203,14 @@ final class PlayerService {
/// - audioStream: Optional separate audio stream (for video-only streams)
/// - startTime: Optional start time in seconds
func play(video: Video, stream: Stream? = nil, audioStream: Stream? = nil, startTime: TimeInterval? = nil) async {
// Downloaded/local files bypass stream selection (they arrive here as
// ready-made file:// streams), so audio mode is applied at this choke
// point instead of in selectStreams.
var stream = stream
var audioStream = audioStream
if let provided = stream {
(stream, audioStream) = applyingAudioModeToLocalStreams(video: video, stream: provided, audioStream: audioStream)
}
// Set up audio session when playback actually starts (not at app launch)
setupAudioSession()
@@ -1378,6 +1386,37 @@ final class PlayerService {
await play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime)
}
/// Applies audio-only mode to local (downloaded) streams. Online streams
/// are handled by stream selection; local files reach `play()` pre-resolved,
/// so the swap happens here instead.
private func applyingAudioModeToLocalStreams(video: Video, stream: Stream, audioStream: Stream?) -> (Stream, Stream?) {
guard stream.url.isFileURL else { return (stream, audioStream) }
if settingsManager?.audioOnlyModeEnabled == true {
guard !stream.isAudioOnly else { return (stream, audioStream) }
if let audioStream, audioStream.url.isFileURL {
// Separate downloaded audio track - play it on its own
LoggingService.shared.logPlayer("Audio mode: using downloaded audio track for \(video.id.id)")
return (audioStream, nil)
}
// Muxed file - same URL with the video track disabled at load time
LoggingService.shared.logPlayer("Audio mode: playing muxed local file without video track for \(video.id.id)")
return (stream.audioOnlyVariant(), nil)
}
// Mode is off but an audio-only stream was stored for a downloaded
// video (e.g. queue/history item created while audio mode was on) -
// restore the full local streams from the download record.
if stream.isAudioOnly,
let downloadManager,
let download = downloadManager.download(for: video.id),
download.status == .completed,
let (_, videoStream, downloadAudio, _, _) = downloadManager.videoAndStream(for: download) {
return (videoStream, downloadAudio)
}
return (stream, audioStream)
}
/// Toggles global audio-only ("music") mode. If an online video is playing,
/// reloads it at the current position (same mechanism as a quality switch).
func setAudioMode(_ enabled: Bool) async {
@@ -1385,11 +1424,27 @@ final class PlayerService {
settingsManager?.audioOnlyModeEnabled = enabled
LoggingService.shared.logPlayer("Audio mode \(enabled ? "enabled" : "disabled")")
// Nothing playing, no stream list to reselect from (e.g. still loading),
// or playing a downloaded/local file: just persist the setting.
guard let video = state.currentVideo,
currentDownload == nil,
!availableStreams.isEmpty else { return }
// Nothing playing: just persist the setting.
guard state.currentVideo != nil else { return }
// Downloaded content: re-derive the local streams from the download
// record and reload; play() applies the mode swap for local files
// (separate audio track, or same file with the video track disabled).
if let currentDownload {
guard (state.currentStream?.isAudioOnly == true) != enabled,
let downloadManager,
let (downloadedVideo, localStream, localAudio, captionURL, _) = downloadManager.videoAndStream(for: currentDownload)
else { return }
let currentTime = state.currentTime
await play(video: downloadedVideo, stream: localStream, audioStream: localAudio, startTime: currentTime)
if let captionURL {
loadLocalCaption(url: captionURL)
}
return
}
// No stream list to reselect from (e.g. still loading): just persist.
guard let video = state.currentVideo, !availableStreams.isEmpty else { return }
let currentTime = state.currentTime