From 06087220ef88ef3a361f4f0b90404cfeb4280cc9 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 13 Jul 2026 19:06:01 +0200 Subject: [PATCH] Add audio-only music mode Persistent per-platform mode that plays only the audio track to save bandwidth and speed up loads. When enabled, stream selection picks the best audio-only stream (preferred language, codec, bitrate) and never fetches the video URL; toggling mid-video reloads at the current position using the quality-switch path (live vid=no toggling is avoided due to A/V desync). - Toggle row in the player quality/settings sheet on iOS, macOS and tvOS, next to lock controls - New audioMode control button type (red when active) for player controls and mini player layouts - Picking a video quality explicitly turns the mode off; audio track picks keep it on and now work while audio-only is playing - Stream URL refresh preserves audio-only playback instead of resurrecting video - Queue preloads and history entries resolved before a toggle are discarded so selection re-runs with the current mode - tvOS player shows the video thumbnail instead of a black screen during audio-only playback - PiP button hidden for audio-only streams --- Yattee/Core/PlaybackCommands.swift | 2 +- Yattee/Core/Settings/SettingsKey.swift | 3 +- .../Settings/SettingsManager+Playback.swift | 13 ++ Yattee/Core/SettingsManager.swift | 2 + Yattee/Localizable.xcstrings | 21 +++ .../PlayerControls/ControlButtonType.swift | 13 +- Yattee/Services/Player/PlayerService.swift | 173 +++++++++++++----- .../Player/ControlsSectionRenderer.swift | 19 ++ .../ExpandedPlayerSheet+VideoInfo.swift | 11 +- Yattee/Views/Player/ExpandedPlayerSheet.swift | 6 + Yattee/Views/Player/MPVVideoView.swift | 4 +- Yattee/Views/Player/MiniPlayerView.swift | 17 ++ .../Views/Player/PlayerControlsActions.swift | 12 +- Yattee/Views/Player/PlayerControlsView.swift | 6 + .../Player/QualitySelectorView+Sections.swift | 57 +++++- Yattee/Views/Player/QualitySelectorView.swift | 14 +- .../macOS/MacOSControlsSectionRenderer.swift | 13 ++ .../macOS/MacOSPlayerControlsView.swift | 6 + Yattee/Views/Player/tvOS/TVPlayerView.swift | 45 +++-- 19 files changed, 352 insertions(+), 85 deletions(-) diff --git a/Yattee/Core/PlaybackCommands.swift b/Yattee/Core/PlaybackCommands.swift index e8fa86b9..a65e6607 100644 --- a/Yattee/Core/PlaybackCommands.swift +++ b/Yattee/Core/PlaybackCommands.swift @@ -306,7 +306,7 @@ struct PlaybackCommands: Commands { Text(String(localized: "menu.playback.pip")) } .keyboardShortcut("i", modifiers: [.command, .shift]) - .disabled(!hasActiveVideo || !state.isPiPPossible) + .disabled(!hasActiveVideo || !state.isPiPPossible || state.currentStream?.isAudioOnly == true) } // MARK: - Close video button diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index 78a82e91..69fa0691 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -29,6 +29,7 @@ enum SettingsKey: String, CaseIterable { case resumeAction case tvOSMenuButtonClosesVideo case allowSoftwareDecodedFormats + case audioOnlyMode // SponsorBlock case sponsorBlockEnabled @@ -144,7 +145,7 @@ enum SettingsKey: String, CaseIterable { /// in both UserDefaults and iCloud, so each platform family syncs independently. var isPlatformSpecific: Bool { switch self { - case .preferredQuality, .cellularQuality, .allowSoftwareDecodedFormats, + case .preferredQuality, .cellularQuality, .allowSoftwareDecodedFormats, .audioOnlyMode, .macPlayerSeparateWindow, .macPlayerFloating, .listStyle, .macControlsBarOffsetX, .macControlsBarOffsetY, // Home layout — different UI paradigms per platform diff --git a/Yattee/Core/Settings/SettingsManager+Playback.swift b/Yattee/Core/Settings/SettingsManager+Playback.swift index 318a0657..226d7237 100644 --- a/Yattee/Core/Settings/SettingsManager+Playback.swift +++ b/Yattee/Core/Settings/SettingsManager+Playback.swift @@ -81,6 +81,19 @@ extension SettingsManager { } } + /// Audio-only ("music") mode: when enabled, only the audio track is loaded + /// for every video until turned off. Persisted per platform. + var audioOnlyModeEnabled: Bool { + get { + if let cached = _audioOnlyModeEnabled { return cached } + return bool(for: .audioOnlyMode, default: false) + } + set { + _audioOnlyModeEnabled = newValue + set(newValue, for: .audioOnlyMode) + } + } + /// Preferred audio language code (e.g., "en", "de", "ja"). /// When set, audio streams in this language will be auto-selected and shown first. /// nil means no preference (use original/default audio). diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index d59717bd..0e6abfff 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -44,6 +44,7 @@ final class SettingsManager { var _resumeAction: ResumeAction? var _tvOSMenuButtonClosesVideo: Bool? var _allowSoftwareDecodedFormats: Bool? + var _audioOnlyModeEnabled: Bool? // SponsorBlock var _sponsorBlockEnabled: Bool? @@ -435,6 +436,7 @@ final class SettingsManager { _resumeAction = nil _tvOSMenuButtonClosesVideo = nil _allowSoftwareDecodedFormats = nil + _audioOnlyModeEnabled = nil _sponsorBlockEnabled = nil _sponsorBlockCategories = nil _sponsorBlockAPIURL = nil diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 264b131c..d99b6294 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -1389,6 +1389,16 @@ } } }, + "controls.button.audioMode" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audio Mode" + } + } + } + }, "controls.button.autoPlayNext" : { "localizations" : { "en" : { @@ -7687,6 +7697,17 @@ } } }, + "player.quality.audioMode" : { + "comment" : "Audio-only (music) mode toggle in the player settings sheet", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audio Mode" + } + } + } + }, "player.quality.loading" : { "comment" : "Loading streams message in quality selector", "localizations" : { diff --git a/Yattee/Models/PlayerControls/ControlButtonType.swift b/Yattee/Models/PlayerControls/ControlButtonType.swift index 01e1aaab..72cbf948 100644 --- a/Yattee/Models/PlayerControls/ControlButtonType.swift +++ b/Yattee/Models/PlayerControls/ControlButtonType.swift @@ -39,6 +39,7 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { case titleAuthor case panscan case autoPlayNext + case audioMode case seek case keepOnTop @@ -116,6 +117,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { return String(localized: "controls.button.panscan") case .autoPlayNext: return String(localized: "controls.button.autoPlayNext") + case .audioMode: + return String(localized: "controls.button.audioMode") case .seek: return String(localized: "controls.button.seek") case .keepOnTop: @@ -186,6 +189,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { return "arrow.left.and.right.square" case .autoPlayNext: return "play.square.stack.fill" + case .audioMode: + return "music.note" case .seek: return "goforward.10" // Default icon, actual icon is determined by settings case .keepOnTop: @@ -250,6 +255,7 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { .share, .contextMenu, .autoPlayNext, + .audioMode, .mpvDebug ] #else @@ -281,6 +287,7 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { .panelToggle, .panscan, .autoPlayNext, + .audioMode, .airplay, .mpvDebug ] @@ -332,7 +339,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { .addToPlaylist, .pictureInPicture, // Utility - .playbackSpeed + .playbackSpeed, + .audioMode ] #else [ @@ -350,7 +358,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { .airplay, .pictureInPicture, // Utility - .playbackSpeed + .playbackSpeed, + .audioMode ] #endif } diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index b4d90476..a685f4f1 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -810,7 +810,15 @@ final class PlayerService { if !playedFromDownload { currentDownload = nil - await play(video: queuedVideo.video, stream: queuedVideo.stream, audioStream: queuedVideo.audioStream, startTime: 0) + var stream = queuedVideo.stream + var audioStream = queuedVideo.audioStream + if let preResolved = stream, !preResolved.url.isFileURL, + preResolved.isAudioOnly != (settingsManager?.audioOnlyModeEnabled == true) { + // Pre-resolved before an audio-mode toggle - discard so selection re-runs + stream = nil + audioStream = nil + } + await play(video: queuedVideo.video, stream: stream, audioStream: audioStream, startTime: 0) // Load captions if available if !queuedVideo.captions.isEmpty { self.availableCaptions = queuedVideo.captions @@ -1083,8 +1091,17 @@ final class PlayerService { state.insertNext(currentVideo, stream: state.currentStream, audioStream: state.currentAudioStream) } + var stream = previous.stream + var audioStream = previous.audioStream + if let preResolved = stream, !preResolved.url.isFileURL, + preResolved.isAudioOnly != (settingsManager?.audioOnlyModeEnabled == true) { + // Resolved before an audio-mode toggle - discard so selection re-runs + stream = nil + audioStream = nil + } + // Play the previous video, resuming from saved position - await play(video: previous.video, stream: previous.stream, audioStream: previous.audioStream, startTime: previous.startTime) + await play(video: previous.video, stream: stream, audioStream: audioStream, startTime: previous.startTime) } else { // No history, just restart current video await seek(to: 0) @@ -1144,14 +1161,21 @@ final class PlayerService { self.availableCaptions = captions // Find matching stream or best alternative - let newStream = findMatchingStream(in: streams, preferring: state.currentStream) + let newStream: Stream let newAudioStream: Stream? - if newStream.isVideoOnly { - // Find matching audio stream - newAudioStream = findMatchingAudioStream(in: streams, preferring: state.currentAudioStream) - } else { + if state.currentStream?.isAudioOnly == true || settingsManager?.audioOnlyModeEnabled == true, + let refreshedAudio = findMatchingAudioStream(in: streams, preferring: state.currentStream) + { + // Preserve audio-only playback (audio mode or audio-only content); + // `findMatchingAudioStream` matches the current track's language + newStream = refreshedAudio newAudioStream = nil + } else { + newStream = findMatchingStream(in: streams, preferring: state.currentStream) + newAudioStream = newStream.isVideoOnly + ? findMatchingAudioStream(in: streams, preferring: state.currentAudioStream) + : nil } LoggingService.shared.logPlayer("Resuming with stream: \(newStream.qualityLabel) at \(resumeTime ?? 0)s") @@ -1337,6 +1361,13 @@ final class PlayerService { func switchToOnlineStream(_ stream: Stream, audioStream: Stream? = nil) async { guard let video = state.currentVideo else { return } + // Picking a video stream while audio mode is on turns the mode off + // so the explicit choice sticks (same rule as selectStreamManually) + if !stream.isAudioOnly, settingsManager?.audioOnlyModeEnabled == true { + settingsManager?.audioOnlyModeEnabled = false + LoggingService.shared.logPlayer("Audio mode disabled by explicit quality selection") + } + // Clear the download flag since we're now playing online currentDownload = nil @@ -1347,6 +1378,55 @@ final class PlayerService { await play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime) } + /// 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 { + guard settingsManager?.audioOnlyModeEnabled != enabled else { return } + 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 } + + let currentTime = state.currentTime + + if enabled { + // Already audio-only (e.g. audio-only content) - nothing to reload + guard state.currentStream?.isAudioOnly != true else { return } + guard let bestAudio = bestAudioStream(from: availableStreams.filter { $0.isAudioOnly }) else { + LoggingService.shared.logPlayer("Audio mode: no audio-only streams for current video, keeping current stream") + return + } + await play(video: video, stream: bestAudio, audioStream: nil, startTime: currentTime) + } else { + // Re-run normal selection with the mode now off + let selection = selectStreamAndBackend(from: availableStreams) + guard let stream = selection.stream else { return } + // Audio-only content still selects audio - no reload needed + if stream.isAudioOnly, state.currentStream?.isAudioOnly == true { return } + await play(video: video, stream: stream, audioStream: selection.audioStream, startTime: currentTime) + } + } + + /// Handles an explicit stream pick from the quality selector, switching at + /// the current playback position. Picking a video stream while audio mode + /// is on turns audio mode off so the choice sticks. + func selectStreamManually(_ stream: Stream, audioStream: Stream?) async { + guard let video = state.currentVideo else { return } + + if !stream.isAudioOnly, settingsManager?.audioOnlyModeEnabled == true { + settingsManager?.audioOnlyModeEnabled = false + LoggingService.shared.logPlayer("Audio mode disabled by explicit quality selection") + } + + let currentTime = state.currentTime + LoggingService.shared.logPlayer("Manual stream switch to \(stream.qualityLabel) at \(currentTime)s") + await play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime) + } + // MARK: - Media Browser Stream Resolution /// Resolves stream and captions for a media browser video on-demand. @@ -1953,6 +2033,15 @@ final class PlayerService { let audioStreams = streams.filter { $0.isAudioOnly } + // Audio-only ("music") mode: skip the video track entirely + if settingsManager?.audioOnlyModeEnabled == true { + if let bestAudio = bestAudioStream(from: audioStreams) { + LoggingService.shared.debug("Stream selection: Audio mode enabled, selecting audio-only stream", category: .player) + return (bestAudio, nil) + } + LoggingService.shared.warning("Stream selection: Audio mode enabled but no audio-only streams available, falling back to normal selection", category: .player) + } + // Check for audio-only content (no real video streams available) // This handles cases like SoundCloud where HLS is audio-only but not marked as such let hasRealVideoStreams = !videoOnlyStreams.isEmpty || muxedStreams.contains { stream in @@ -1965,10 +2054,8 @@ final class PlayerService { return true } - if !hasRealVideoStreams && !audioStreams.isEmpty { + if !hasRealVideoStreams, let bestAudio = bestAudioStream(from: audioStreams) { LoggingService.shared.debug("Stream selection: Audio-only content detected, using best audio stream", category: .player) - // Select best audio stream by bitrate - let bestAudio = audioStreams.sorted { ($0.bitrate ?? 0) > ($1.bitrate ?? 0) }.first! return (bestAudio, nil) } @@ -2027,41 +2114,8 @@ final class PlayerService { return videoCodecPriority(s1.videoCodec) > videoCodecPriority(s2.videoCodec) } - if let bestVideo = sortedVideo.first { - // Select best audio stream based on preferred language, codec, and bitrate - let preferredAudioLanguage = settingsManager?.preferredAudioLanguage - let bestAudio = audioStreams - .sorted { stream1, stream2 in - // First priority: preferred language or original audio - if let preferred = preferredAudioLanguage { - // User selected a specific language - let lang1 = stream1.audioLanguage ?? "" - let lang2 = stream2.audioLanguage ?? "" - let matches1 = lang1.hasPrefix(preferred) - let matches2 = lang2.hasPrefix(preferred) - if matches1 != matches2 { return matches1 } - } else { - // No preference set - prefer original audio track - if stream1.isOriginalAudio != stream2.isOriginalAudio { - return stream1.isOriginalAudio - } - } - - // Second priority: prefer Opus > AAC for MPV (better quality/compression) - let codecPriority1 = audioCodecPriority(stream1.audioCodec) - let codecPriority2 = audioCodecPriority(stream2.audioCodec) - if codecPriority1 != codecPriority2 { - return codecPriority1 > codecPriority2 - } - - // Third priority: higher bitrate - return (stream1.bitrate ?? 0) > (stream2.bitrate ?? 0) - } - .first - - if let audio = bestAudio { - return (bestVideo, audio) - } + if let bestVideo = sortedVideo.first, let audio = bestAudioStream(from: audioStreams) { + return (bestVideo, audio) } } @@ -2116,6 +2170,35 @@ final class PlayerService { HardwareCapabilities.shared.codecPriority(for: codec) } + /// Selects the best audio-only stream: preferred language (or original audio + /// when no preference is set), then codec priority, then bitrate. + private func bestAudioStream(from audioStreams: [Stream]) -> Stream? { + let preferredAudioLanguage = settingsManager?.preferredAudioLanguage + return audioStreams + .sorted { stream1, stream2 in + if let preferred = preferredAudioLanguage { + // User selected a specific language + let matches1 = (stream1.audioLanguage ?? "").hasPrefix(preferred) + let matches2 = (stream2.audioLanguage ?? "").hasPrefix(preferred) + if matches1 != matches2 { return matches1 } + } else { + // No preference set - prefer original audio track + if stream1.isOriginalAudio != stream2.isOriginalAudio { + return stream1.isOriginalAudio + } + } + + let codecPriority1 = audioCodecPriority(stream1.audioCodec) + let codecPriority2 = audioCodecPriority(stream2.audioCodec) + if codecPriority1 != codecPriority2 { + return codecPriority1 > codecPriority2 + } + + return (stream1.bitrate ?? 0) > (stream2.bitrate ?? 0) + } + .first + } + /// Returns codec priority for audio streams. /// Opus and AAC are treated equally - let bitrate decide quality. private func audioCodecPriority(_ codec: String?) -> Int { diff --git a/Yattee/Views/Player/ControlsSectionRenderer.swift b/Yattee/Views/Player/ControlsSectionRenderer.swift index 64951d98..95c3c0b2 100644 --- a/Yattee/Views/Player/ControlsSectionRenderer.swift +++ b/Yattee/Views/Player/ControlsSectionRenderer.swift @@ -275,6 +275,11 @@ struct ControlsSectionRenderer: View { .disabled(isLocked) .opacity(isLocked ? 0.5 : 1.0) + case .audioMode: + audioModeButton + .disabled(isLocked) + .opacity(isLocked ? 0.5 : 1.0) + case .seekBackward, .seekForward: // These are center section only buttons, not rendered here EmptyView() @@ -1021,6 +1026,20 @@ struct ControlsSectionRenderer: View { } } } + + // MARK: - Audio Mode Button + + @ViewBuilder + private var audioModeButton: some View { + if actions.onToggleAudioMode != nil { + controlButton( + systemImage: "music.note", + tint: actions.isAudioModeEnabled ? .red : .white + ) { + actions.onToggleAudioMode?() + } + } + } } // MARK: - Optional Glass Background Modifier diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift b/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift index a4ca8117..e23296af 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift @@ -258,17 +258,8 @@ extension ExpandedPlayerSheet { /// Switches to a different stream. func switchToStream(_ stream: Stream, audioStream: Stream? = nil) { - guard let video = playerState?.currentVideo else { return } - - // Capture current playback position before switching streams - let currentTime = playerState?.currentTime - // Also get time directly from backend as backup - let backendTime = playerService?.currentBackend?.currentTime - - LoggingService.shared.logPlayer("switchToStream: stateTime=\(currentTime ?? -1), backendTime=\(backendTime ?? -1), switching to \(stream.qualityLabel)") - Task { - await playerService?.play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime) + await playerService?.selectStreamManually(stream, audioStream: audioStream) } } } diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index 1b55d3ab..7ada85b1 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -696,6 +696,7 @@ private struct PlayerSheetsModifier: ViewModifier { }, currentRate: playerState?.rate ?? .x1, isControlsLocked: playerState?.isControlsLocked ?? false, + isAudioMode: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false, onStreamSelected: { stream, audioStream in onStreamSelected(stream, audioStream) }, @@ -718,6 +719,11 @@ private struct PlayerSheetsModifier: ViewModifier { }, onLockToggled: { locked in playerState?.isControlsLocked = locked + }, + onAudioModeToggled: { enabled in + Task { + await playerService.setAudioMode(enabled) + } } ) } diff --git a/Yattee/Views/Player/MPVVideoView.swift b/Yattee/Views/Player/MPVVideoView.swift index 003f5bd7..6c326c54 100644 --- a/Yattee/Views/Player/MPVVideoView.swift +++ b/Yattee/Views/Player/MPVVideoView.swift @@ -258,10 +258,8 @@ struct MPVVideoView: View { playerService.loadCaption(caption) }, onStreamSelected: { stream, audioStream in - guard let video = playerState.currentVideo else { return } - let currentTime = playerState.currentTime Task { - await playerService.play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime) + await playerService.selectStreamManually(stream, audioStream: audioStream) } } ) diff --git a/Yattee/Views/Player/MiniPlayerView.swift b/Yattee/Views/Player/MiniPlayerView.swift index b67d85e9..8cfed066 100644 --- a/Yattee/Views/Player/MiniPlayerView.swift +++ b/Yattee/Views/Player/MiniPlayerView.swift @@ -458,6 +458,8 @@ struct MiniPlayerView: View { pipButton(config: config) case .playbackSpeed: playbackSpeedButton(config: config) + case .audioMode: + audioModeButton(config: config) default: EmptyView() } @@ -654,6 +656,21 @@ struct MiniPlayerView: View { .tint(.primary) } + private func audioModeButton(config: ControlButtonConfiguration) -> some View { + let isAudioMode = appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false + return Button { + incrementTapCount(for: config) + Task { await playerService?.setAudioMode(!isAudioMode) } + } label: { + Image(systemName: "music.note") + .font(isTabAccessory ? .title3 : .title2) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + .foregroundStyle(isAudioMode ? AnyShapeStyle(Color.red) : AnyShapeStyle(.primary)) + } + .buttonStyle(.plain) + } + // MARK: - Helpers /// Whether transport controls should be disabled (during loading/buffering or buffer not ready) diff --git a/Yattee/Views/Player/PlayerControlsActions.swift b/Yattee/Views/Player/PlayerControlsActions.swift index 6a86cbf7..6e7aa2ae 100644 --- a/Yattee/Views/Player/PlayerControlsActions.swift +++ b/Yattee/Views/Player/PlayerControlsActions.swift @@ -87,6 +87,9 @@ struct PlayerControlsActions { /// Whether auto-play next is enabled let isAutoPlayNextEnabled: Bool + /// Whether audio-only (music) mode is enabled + var isAudioModeEnabled: Bool = false + /// Yattee Server URL for channel avatar fallback let yatteeServerURL: URL? @@ -126,6 +129,9 @@ struct PlayerControlsActions { /// Toggle auto-play next in queue var onToggleAutoPlayNext: (() -> Void)? + /// Toggle audio-only (music) mode + var onToggleAudioMode: (() -> Void)? + /// Show settings sheet var onShowSettings: (() -> Void)? @@ -304,9 +310,11 @@ struct PlayerControlsActions { } #endif - /// Whether PiP button should be enabled + /// Whether PiP button should be enabled. + /// No video track in audio-only playback - `isPiPPossible` may lag behind + /// a video-to-audio reload on a reused backend, so gate explicitly. var isPiPAvailable: Bool { - playerState.isPiPPossible + playerState.isPiPPossible && playerState.currentStream?.isAudioOnly != true } /// Whether play next button should be enabled diff --git a/Yattee/Views/Player/PlayerControlsView.swift b/Yattee/Views/Player/PlayerControlsView.swift index 9f805aa3..ff9103dc 100644 --- a/Yattee/Views/Player/PlayerControlsView.swift +++ b/Yattee/Views/Player/PlayerControlsView.swift @@ -382,6 +382,7 @@ struct PlayerControlsView: View { panscanValue: panscanValue, isPanscanAllowed: isPanscanAllowed, isAutoPlayNextEnabled: appEnvironment?.settingsManager.queueAutoPlayNext ?? true, + isAudioModeEnabled: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false, yatteeServerURL: appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url, deArrowBrandingProvider: appEnvironment?.deArrowBrandingProvider, onClose: onClose, @@ -395,6 +396,11 @@ struct PlayerControlsView: View { onToggleAutoPlayNext: { appEnvironment?.settingsManager.queueAutoPlayNext.toggle() }, + onToggleAudioMode: { [weak appEnvironment] in + guard let appEnvironment else { return } + let enabled = !appEnvironment.settingsManager.audioOnlyModeEnabled + Task { await appEnvironment.playerService.setAudioMode(enabled) } + }, onShowSettings: onShowSettings, onPlayNext: onPlayNext, onPlayPrevious: onPlayPrevious, diff --git a/Yattee/Views/Player/QualitySelectorView+Sections.swift b/Yattee/Views/Player/QualitySelectorView+Sections.swift index a06ebfcb..61399a66 100644 --- a/Yattee/Views/Player/QualitySelectorView+Sections.swift +++ b/Yattee/Views/Player/QualitySelectorView+Sections.swift @@ -323,14 +323,22 @@ extension QualitySelectorView { @ViewBuilder var generalSectionContent: some View { #if os(tvOS) - // tvOS only has the speed row here; style it to match the Settings rows. - playbackSpeedRow + // tvOS rows are styled individually to match the Settings rows. + VStack(spacing: 8) { + playbackSpeedRow + + audioModeRow + } #else VStack(spacing: 0) { playbackSpeedRow Divider() + audioModeRow + + Divider() + lockControlsRow } .cardBackground() @@ -408,6 +416,45 @@ extension QualitySelectorView { #endif } + @ViewBuilder + private var audioModeRow: some View { + #if os(tvOS) + Button { + onAudioModeToggled?(!isAudioMode) + } label: { + HStack { + Label(String(localized: "player.quality.audioMode"), systemImage: "music.note") + .font(.headline) + + Spacer() + + Text(isAudioMode ? String(localized: "common.on") : String(localized: "common.off")) + .foregroundStyle(.secondary) + } + .padding(.vertical, 14) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(TVSettingsRowButtonStyle()) + #else + HStack { + Label(String(localized: "player.quality.audioMode"), systemImage: "music.note") + .font(.headline) + + Spacer() + + Toggle("", isOn: Binding( + get: { isAudioMode }, + set: { onAudioModeToggled?($0) } + )) + .labelsHidden() + } + .padding(.vertical, 12) + .padding(.horizontal, 12) + #endif + } + #if !os(tvOS) @ViewBuilder private var lockControlsRow: some View { @@ -751,7 +798,11 @@ extension QualitySelectorView { private func handleAudioStreamTap(_ stream: Stream) { selectedAudioStream = stream - if let video = selectedVideoStream, video.isVideoOnly { + if currentStream?.isAudioOnly == true { + // Audio mode / audio-only content: the tapped track IS the main stream + onStreamSelected(stream, nil) + performDismiss() + } else if let video = selectedVideoStream, video.isVideoOnly { onStreamSelected(video, stream) performDismiss() } diff --git a/Yattee/Views/Player/QualitySelectorView.swift b/Yattee/Views/Player/QualitySelectorView.swift index 94c5f2bd..030ff009 100644 --- a/Yattee/Views/Player/QualitySelectorView.swift +++ b/Yattee/Views/Player/QualitySelectorView.swift @@ -39,6 +39,11 @@ struct QualitySelectorView: View { /// Callback when lock state changes var onLockToggled: ((Bool) -> Void)? + /// Whether audio-only (music) mode is enabled + var isAudioMode: Bool = false + /// Callback when audio mode is toggled + var onAudioModeToggled: ((Bool) -> Void)? + /// Initial tab to show when view appears var initialTab: QualitySelectorTab = .video /// Whether to show the segmented tab picker (false for focused single-tab mode) @@ -160,6 +165,7 @@ struct QualitySelectorView: View { localCaptionURL: URL? = nil, currentRate: PlaybackRate = .x1, isControlsLocked: Bool = false, + isAudioMode: Bool = false, initialTab: QualitySelectorTab = .video, showTabPicker: Bool = true, onStreamSelected: @escaping (Stream, Stream?) -> Void, @@ -168,6 +174,7 @@ struct QualitySelectorView: View { onSwitchToOnlineStream: @escaping (Stream, Stream?) -> Void = { _, _ in }, onRateChanged: ((PlaybackRate) -> Void)? = nil, onLockToggled: ((Bool) -> Void)? = nil, + onAudioModeToggled: ((Bool) -> Void)? = nil, onDismiss: (() -> Void)? = nil ) { self.streams = streams @@ -183,12 +190,14 @@ struct QualitySelectorView: View { self.showTabPicker = showTabPicker self.currentRate = currentRate self.isControlsLocked = isControlsLocked + self.isAudioMode = isAudioMode self.onStreamSelected = onStreamSelected self.onCaptionSelected = onCaptionSelected self.onLoadOnlineStreams = onLoadOnlineStreams self.onSwitchToOnlineStream = onSwitchToOnlineStream self.onRateChanged = onRateChanged self.onLockToggled = onLockToggled + self.onAudioModeToggled = onAudioModeToggled self.onDismiss = onDismiss } @@ -291,7 +300,10 @@ struct QualitySelectorView: View { } .onAppear { selectedVideoStream = currentStream - selectedAudioStream = currentAudioStream ?? defaultAudioStream + // In audio mode the audio track IS the main stream + selectedAudioStream = currentStream?.isAudioOnly == true + ? currentStream + : (currentAudioStream ?? defaultAudioStream) #if os(tvOS) // Defer until after the slide-in transition so the focus engine // has finished routing focus away from the (now hidden) player diff --git a/Yattee/Views/Player/macOS/MacOSControlsSectionRenderer.swift b/Yattee/Views/Player/macOS/MacOSControlsSectionRenderer.swift index 5993c49b..995d7e9e 100644 --- a/Yattee/Views/Player/macOS/MacOSControlsSectionRenderer.swift +++ b/Yattee/Views/Player/macOS/MacOSControlsSectionRenderer.swift @@ -271,6 +271,19 @@ struct MacOSControlsSectionRenderer: View { .opacity(isLocked ? 0.5 : 1.0) } + case .audioMode: + if actions.onToggleAudioMode != nil { + controlButton( + systemImage: "music.note", + tint: actions.isAudioModeEnabled ? .red : tint, + help: config.buttonType.displayName + ) { + actions.onToggleAudioMode?() + } + .disabled(isLocked) + .opacity(isLocked ? 0.5 : 1.0) + } + case .seek: if let settings = config.seekSettings { seekButton(settings: settings, config: config) diff --git a/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift b/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift index 828617a8..dad2875e 100644 --- a/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift +++ b/Yattee/Views/Player/macOS/MacOSPlayerControlsView.swift @@ -211,6 +211,7 @@ struct MacOSPlayerControlsView: View { currentStream: nil, currentAudioStream: nil, isAutoPlayNextEnabled: appEnvironment?.settingsManager.queueAutoPlayNext ?? true, + isAudioModeEnabled: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false, yatteeServerURL: yatteeServerURL, deArrowBrandingProvider: appEnvironment?.deArrowBrandingProvider, onClose: onClose, @@ -225,6 +226,11 @@ struct MacOSPlayerControlsView: View { onToggleAutoPlayNext: { [weak appEnvironment] in appEnvironment?.settingsManager.queueAutoPlayNext.toggle() }, + onToggleAudioMode: { [weak appEnvironment] in + guard let appEnvironment else { return } + let enabled = !appEnvironment.settingsManager.audioOnlyModeEnabled + Task { await appEnvironment.playerService.setAudioMode(enabled) } + }, onShowSettings: onShowSettings, onPlayNext: onPlayNext, onPlayPrevious: onPlayPrevious, diff --git a/Yattee/Views/Player/tvOS/TVPlayerView.swift b/Yattee/Views/Player/tvOS/TVPlayerView.swift index 8c0e6f86..50ae6f3c 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerView.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerView.swift @@ -166,6 +166,7 @@ struct TVPlayerView: View { return appEnvironment?.downloadManager.downloadsDirectory().appendingPathComponent(path) }, currentRate: playerState?.rate ?? .x1, + isAudioMode: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false, onStreamSelected: { stream, audioStream in switchToStream(stream, audioStream: audioStream) }, @@ -186,6 +187,11 @@ struct TVPlayerView: View { playerState?.rate = rate playerService.currentBackend?.rate = Float(rate.rawValue) }, + onAudioModeToggled: { enabled in + Task { + await playerService.setAudioMode(enabled) + } + }, onDismiss: { hideQualitySheet() } ) } @@ -606,18 +612,27 @@ struct TVPlayerView: View { @ViewBuilder private var videoLayer: some View { - if let playerService, - let backend = playerService.currentBackend as? MPVBackend, - let playerState { - MPVRenderViewRepresentable( - backend: backend, - playerState: playerState - ) - .ignoresSafeArea() - .allowsHitTesting(false) - } else { - // Fallback/loading state - show thumbnail - if let video = playerState?.currentVideo, + let isAudioOnly = playerState?.currentStream?.isAudioOnly == true + let hasBackend = playerService?.currentBackend is MPVBackend + + ZStack { + if let playerService, + let backend = playerService.currentBackend as? MPVBackend, + let playerState { + MPVRenderViewRepresentable( + backend: backend, + playerState: playerState + ) + .ignoresSafeArea() + .allowsHitTesting(false) + // Keep the render view alive but hidden for audio-only + // playback so toggling audio mode doesn't tear down GL state + .opacity(isAudioOnly ? 0 : 1) + } + + // Thumbnail for audio-only playback and the pre-backend loading state + if isAudioOnly || !hasBackend, + let video = playerState?.currentVideo, let thumbnailURL = video.bestThumbnail?.url { AsyncImage(url: thumbnailURL) { image in image @@ -735,12 +750,8 @@ struct TVPlayerView: View { } private func switchToStream(_ stream: Stream, audioStream: Stream? = nil) { - guard let video = playerState?.currentVideo else { return } - - let currentTime = playerState?.currentTime - Task { - await playerService?.play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime) + await playerService?.selectStreamManually(stream, audioStream: audioStream) } }