From c2554cee0404a9785a6eed457532a326ee114040 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 17 Jul 2026 09:27:09 +0200 Subject: [PATCH] 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. --- Yattee/Models/Stream.swift | 32 +++++++++++ Yattee/Services/Player/MPV/MPVClient.swift | 13 ++++- Yattee/Services/Player/MPVBackend.swift | 8 ++- Yattee/Services/Player/PlayerService.swift | 65 ++++++++++++++++++++-- 4 files changed, 111 insertions(+), 7 deletions(-) diff --git a/Yattee/Models/Stream.swift b/Yattee/Models/Stream.swift index 5689d490..ca2f8a85 100644 --- a/Yattee/Models/Stream.swift +++ b/Yattee/Models/Stream.swift @@ -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 { diff --git a/Yattee/Services/Player/MPV/MPVClient.swift b/Yattee/Services/Player/MPV/MPVClient.swift index ac8b1a15..3139c954 100644 --- a/Yattee/Services/Player/MPV/MPVClient.swift +++ b/Yattee/Services/Player/MPV/MPVClient.swift @@ -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 { diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index cca3f559..a2c118dc 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -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)) diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index e95645f0..fdc5c483 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -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