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
This commit is contained in:
Arkadiusz Fal
2026-07-13 19:06:01 +02:00
parent 66f7602d08
commit 06087220ef
19 changed files with 352 additions and 85 deletions

View File

@@ -306,7 +306,7 @@ struct PlaybackCommands: Commands {
Text(String(localized: "menu.playback.pip")) Text(String(localized: "menu.playback.pip"))
} }
.keyboardShortcut("i", modifiers: [.command, .shift]) .keyboardShortcut("i", modifiers: [.command, .shift])
.disabled(!hasActiveVideo || !state.isPiPPossible) .disabled(!hasActiveVideo || !state.isPiPPossible || state.currentStream?.isAudioOnly == true)
} }
// MARK: - Close video button // MARK: - Close video button

View File

@@ -29,6 +29,7 @@ enum SettingsKey: String, CaseIterable {
case resumeAction case resumeAction
case tvOSMenuButtonClosesVideo case tvOSMenuButtonClosesVideo
case allowSoftwareDecodedFormats case allowSoftwareDecodedFormats
case audioOnlyMode
// SponsorBlock // SponsorBlock
case sponsorBlockEnabled case sponsorBlockEnabled
@@ -144,7 +145,7 @@ enum SettingsKey: String, CaseIterable {
/// in both UserDefaults and iCloud, so each platform family syncs independently. /// in both UserDefaults and iCloud, so each platform family syncs independently.
var isPlatformSpecific: Bool { var isPlatformSpecific: Bool {
switch self { switch self {
case .preferredQuality, .cellularQuality, .allowSoftwareDecodedFormats, case .preferredQuality, .cellularQuality, .allowSoftwareDecodedFormats, .audioOnlyMode,
.macPlayerSeparateWindow, .macPlayerFloating, .listStyle, .macPlayerSeparateWindow, .macPlayerFloating, .listStyle,
.macControlsBarOffsetX, .macControlsBarOffsetY, .macControlsBarOffsetX, .macControlsBarOffsetY,
// Home layout different UI paradigms per platform // Home layout different UI paradigms per platform

View File

@@ -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"). /// Preferred audio language code (e.g., "en", "de", "ja").
/// When set, audio streams in this language will be auto-selected and shown first. /// When set, audio streams in this language will be auto-selected and shown first.
/// nil means no preference (use original/default audio). /// nil means no preference (use original/default audio).

View File

@@ -44,6 +44,7 @@ final class SettingsManager {
var _resumeAction: ResumeAction? var _resumeAction: ResumeAction?
var _tvOSMenuButtonClosesVideo: Bool? var _tvOSMenuButtonClosesVideo: Bool?
var _allowSoftwareDecodedFormats: Bool? var _allowSoftwareDecodedFormats: Bool?
var _audioOnlyModeEnabled: Bool?
// SponsorBlock // SponsorBlock
var _sponsorBlockEnabled: Bool? var _sponsorBlockEnabled: Bool?
@@ -435,6 +436,7 @@ final class SettingsManager {
_resumeAction = nil _resumeAction = nil
_tvOSMenuButtonClosesVideo = nil _tvOSMenuButtonClosesVideo = nil
_allowSoftwareDecodedFormats = nil _allowSoftwareDecodedFormats = nil
_audioOnlyModeEnabled = nil
_sponsorBlockEnabled = nil _sponsorBlockEnabled = nil
_sponsorBlockCategories = nil _sponsorBlockCategories = nil
_sponsorBlockAPIURL = nil _sponsorBlockAPIURL = nil

View File

@@ -1389,6 +1389,16 @@
} }
} }
}, },
"controls.button.audioMode" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Audio Mode"
}
}
}
},
"controls.button.autoPlayNext" : { "controls.button.autoPlayNext" : {
"localizations" : { "localizations" : {
"en" : { "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" : { "player.quality.loading" : {
"comment" : "Loading streams message in quality selector", "comment" : "Loading streams message in quality selector",
"localizations" : { "localizations" : {

View File

@@ -39,6 +39,7 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
case titleAuthor case titleAuthor
case panscan case panscan
case autoPlayNext case autoPlayNext
case audioMode
case seek case seek
case keepOnTop case keepOnTop
@@ -116,6 +117,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
return String(localized: "controls.button.panscan") return String(localized: "controls.button.panscan")
case .autoPlayNext: case .autoPlayNext:
return String(localized: "controls.button.autoPlayNext") return String(localized: "controls.button.autoPlayNext")
case .audioMode:
return String(localized: "controls.button.audioMode")
case .seek: case .seek:
return String(localized: "controls.button.seek") return String(localized: "controls.button.seek")
case .keepOnTop: case .keepOnTop:
@@ -186,6 +189,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
return "arrow.left.and.right.square" return "arrow.left.and.right.square"
case .autoPlayNext: case .autoPlayNext:
return "play.square.stack.fill" return "play.square.stack.fill"
case .audioMode:
return "music.note"
case .seek: case .seek:
return "goforward.10" // Default icon, actual icon is determined by settings return "goforward.10" // Default icon, actual icon is determined by settings
case .keepOnTop: case .keepOnTop:
@@ -250,6 +255,7 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
.share, .share,
.contextMenu, .contextMenu,
.autoPlayNext, .autoPlayNext,
.audioMode,
.mpvDebug .mpvDebug
] ]
#else #else
@@ -281,6 +287,7 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
.panelToggle, .panelToggle,
.panscan, .panscan,
.autoPlayNext, .autoPlayNext,
.audioMode,
.airplay, .airplay,
.mpvDebug .mpvDebug
] ]
@@ -332,7 +339,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
.addToPlaylist, .addToPlaylist,
.pictureInPicture, .pictureInPicture,
// Utility // Utility
.playbackSpeed .playbackSpeed,
.audioMode
] ]
#else #else
[ [
@@ -350,7 +358,8 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
.airplay, .airplay,
.pictureInPicture, .pictureInPicture,
// Utility // Utility
.playbackSpeed .playbackSpeed,
.audioMode
] ]
#endif #endif
} }

View File

@@ -810,7 +810,15 @@ final class PlayerService {
if !playedFromDownload { if !playedFromDownload {
currentDownload = nil 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 // Load captions if available
if !queuedVideo.captions.isEmpty { if !queuedVideo.captions.isEmpty {
self.availableCaptions = queuedVideo.captions self.availableCaptions = queuedVideo.captions
@@ -1083,8 +1091,17 @@ final class PlayerService {
state.insertNext(currentVideo, stream: state.currentStream, audioStream: state.currentAudioStream) 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 // 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 { } else {
// No history, just restart current video // No history, just restart current video
await seek(to: 0) await seek(to: 0)
@@ -1144,14 +1161,21 @@ final class PlayerService {
self.availableCaptions = captions self.availableCaptions = captions
// Find matching stream or best alternative // Find matching stream or best alternative
let newStream = findMatchingStream(in: streams, preferring: state.currentStream) let newStream: Stream
let newAudioStream: Stream? let newAudioStream: Stream?
if newStream.isVideoOnly { if state.currentStream?.isAudioOnly == true || settingsManager?.audioOnlyModeEnabled == true,
// Find matching audio stream let refreshedAudio = findMatchingAudioStream(in: streams, preferring: state.currentStream)
newAudioStream = findMatchingAudioStream(in: streams, preferring: state.currentAudioStream) {
} else { // Preserve audio-only playback (audio mode or audio-only content);
// `findMatchingAudioStream` matches the current track's language
newStream = refreshedAudio
newAudioStream = nil 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") 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 { func switchToOnlineStream(_ stream: Stream, audioStream: Stream? = nil) async {
guard let video = state.currentVideo else { return } 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 // Clear the download flag since we're now playing online
currentDownload = nil currentDownload = nil
@@ -1347,6 +1378,55 @@ final class PlayerService {
await play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime) 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 // MARK: - Media Browser Stream Resolution
/// Resolves stream and captions for a media browser video on-demand. /// Resolves stream and captions for a media browser video on-demand.
@@ -1953,6 +2033,15 @@ final class PlayerService {
let audioStreams = streams.filter { $0.isAudioOnly } 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) // 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 // This handles cases like SoundCloud where HLS is audio-only but not marked as such
let hasRealVideoStreams = !videoOnlyStreams.isEmpty || muxedStreams.contains { stream in let hasRealVideoStreams = !videoOnlyStreams.isEmpty || muxedStreams.contains { stream in
@@ -1965,10 +2054,8 @@ final class PlayerService {
return true 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) 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) return (bestAudio, nil)
} }
@@ -2027,41 +2114,8 @@ final class PlayerService {
return videoCodecPriority(s1.videoCodec) > videoCodecPriority(s2.videoCodec) return videoCodecPriority(s1.videoCodec) > videoCodecPriority(s2.videoCodec)
} }
if let bestVideo = sortedVideo.first { if let bestVideo = sortedVideo.first, let audio = bestAudioStream(from: audioStreams) {
// Select best audio stream based on preferred language, codec, and bitrate return (bestVideo, audio)
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)
}
} }
} }
@@ -2116,6 +2170,35 @@ final class PlayerService {
HardwareCapabilities.shared.codecPriority(for: codec) 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. /// Returns codec priority for audio streams.
/// Opus and AAC are treated equally - let bitrate decide quality. /// Opus and AAC are treated equally - let bitrate decide quality.
private func audioCodecPriority(_ codec: String?) -> Int { private func audioCodecPriority(_ codec: String?) -> Int {

View File

@@ -275,6 +275,11 @@ struct ControlsSectionRenderer: View {
.disabled(isLocked) .disabled(isLocked)
.opacity(isLocked ? 0.5 : 1.0) .opacity(isLocked ? 0.5 : 1.0)
case .audioMode:
audioModeButton
.disabled(isLocked)
.opacity(isLocked ? 0.5 : 1.0)
case .seekBackward, .seekForward: case .seekBackward, .seekForward:
// These are center section only buttons, not rendered here // These are center section only buttons, not rendered here
EmptyView() 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 // MARK: - Optional Glass Background Modifier

View File

@@ -258,17 +258,8 @@ extension ExpandedPlayerSheet {
/// Switches to a different stream. /// Switches to a different stream.
func switchToStream(_ stream: Stream, audioStream: Stream? = nil) { 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 { Task {
await playerService?.play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime) await playerService?.selectStreamManually(stream, audioStream: audioStream)
} }
} }
} }

View File

@@ -696,6 +696,7 @@ private struct PlayerSheetsModifier: ViewModifier {
}, },
currentRate: playerState?.rate ?? .x1, currentRate: playerState?.rate ?? .x1,
isControlsLocked: playerState?.isControlsLocked ?? false, isControlsLocked: playerState?.isControlsLocked ?? false,
isAudioMode: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false,
onStreamSelected: { stream, audioStream in onStreamSelected: { stream, audioStream in
onStreamSelected(stream, audioStream) onStreamSelected(stream, audioStream)
}, },
@@ -718,6 +719,11 @@ private struct PlayerSheetsModifier: ViewModifier {
}, },
onLockToggled: { locked in onLockToggled: { locked in
playerState?.isControlsLocked = locked playerState?.isControlsLocked = locked
},
onAudioModeToggled: { enabled in
Task {
await playerService.setAudioMode(enabled)
}
} }
) )
} }

View File

@@ -258,10 +258,8 @@ struct MPVVideoView: View {
playerService.loadCaption(caption) playerService.loadCaption(caption)
}, },
onStreamSelected: { stream, audioStream in onStreamSelected: { stream, audioStream in
guard let video = playerState.currentVideo else { return }
let currentTime = playerState.currentTime
Task { Task {
await playerService.play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime) await playerService.selectStreamManually(stream, audioStream: audioStream)
} }
} }
) )

View File

@@ -458,6 +458,8 @@ struct MiniPlayerView: View {
pipButton(config: config) pipButton(config: config)
case .playbackSpeed: case .playbackSpeed:
playbackSpeedButton(config: config) playbackSpeedButton(config: config)
case .audioMode:
audioModeButton(config: config)
default: default:
EmptyView() EmptyView()
} }
@@ -654,6 +656,21 @@ struct MiniPlayerView: View {
.tint(.primary) .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 // MARK: - Helpers
/// Whether transport controls should be disabled (during loading/buffering or buffer not ready) /// Whether transport controls should be disabled (during loading/buffering or buffer not ready)

View File

@@ -87,6 +87,9 @@ struct PlayerControlsActions {
/// Whether auto-play next is enabled /// Whether auto-play next is enabled
let isAutoPlayNextEnabled: Bool let isAutoPlayNextEnabled: Bool
/// Whether audio-only (music) mode is enabled
var isAudioModeEnabled: Bool = false
/// Yattee Server URL for channel avatar fallback /// Yattee Server URL for channel avatar fallback
let yatteeServerURL: URL? let yatteeServerURL: URL?
@@ -126,6 +129,9 @@ struct PlayerControlsActions {
/// Toggle auto-play next in queue /// Toggle auto-play next in queue
var onToggleAutoPlayNext: (() -> Void)? var onToggleAutoPlayNext: (() -> Void)?
/// Toggle audio-only (music) mode
var onToggleAudioMode: (() -> Void)?
/// Show settings sheet /// Show settings sheet
var onShowSettings: (() -> Void)? var onShowSettings: (() -> Void)?
@@ -304,9 +310,11 @@ struct PlayerControlsActions {
} }
#endif #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 { var isPiPAvailable: Bool {
playerState.isPiPPossible playerState.isPiPPossible && playerState.currentStream?.isAudioOnly != true
} }
/// Whether play next button should be enabled /// Whether play next button should be enabled

View File

@@ -382,6 +382,7 @@ struct PlayerControlsView: View {
panscanValue: panscanValue, panscanValue: panscanValue,
isPanscanAllowed: isPanscanAllowed, isPanscanAllowed: isPanscanAllowed,
isAutoPlayNextEnabled: appEnvironment?.settingsManager.queueAutoPlayNext ?? true, isAutoPlayNextEnabled: appEnvironment?.settingsManager.queueAutoPlayNext ?? true,
isAudioModeEnabled: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false,
yatteeServerURL: appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url, yatteeServerURL: appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url,
deArrowBrandingProvider: appEnvironment?.deArrowBrandingProvider, deArrowBrandingProvider: appEnvironment?.deArrowBrandingProvider,
onClose: onClose, onClose: onClose,
@@ -395,6 +396,11 @@ struct PlayerControlsView: View {
onToggleAutoPlayNext: { onToggleAutoPlayNext: {
appEnvironment?.settingsManager.queueAutoPlayNext.toggle() 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, onShowSettings: onShowSettings,
onPlayNext: onPlayNext, onPlayNext: onPlayNext,
onPlayPrevious: onPlayPrevious, onPlayPrevious: onPlayPrevious,

View File

@@ -323,14 +323,22 @@ extension QualitySelectorView {
@ViewBuilder @ViewBuilder
var generalSectionContent: some View { var generalSectionContent: some View {
#if os(tvOS) #if os(tvOS)
// tvOS only has the speed row here; style it to match the Settings rows. // tvOS rows are styled individually to match the Settings rows.
playbackSpeedRow VStack(spacing: 8) {
playbackSpeedRow
audioModeRow
}
#else #else
VStack(spacing: 0) { VStack(spacing: 0) {
playbackSpeedRow playbackSpeedRow
Divider() Divider()
audioModeRow
Divider()
lockControlsRow lockControlsRow
} }
.cardBackground() .cardBackground()
@@ -408,6 +416,45 @@ extension QualitySelectorView {
#endif #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) #if !os(tvOS)
@ViewBuilder @ViewBuilder
private var lockControlsRow: some View { private var lockControlsRow: some View {
@@ -751,7 +798,11 @@ extension QualitySelectorView {
private func handleAudioStreamTap(_ stream: Stream) { private func handleAudioStreamTap(_ stream: Stream) {
selectedAudioStream = 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) onStreamSelected(video, stream)
performDismiss() performDismiss()
} }

View File

@@ -39,6 +39,11 @@ struct QualitySelectorView: View {
/// Callback when lock state changes /// Callback when lock state changes
var onLockToggled: ((Bool) -> Void)? 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 /// Initial tab to show when view appears
var initialTab: QualitySelectorTab = .video var initialTab: QualitySelectorTab = .video
/// Whether to show the segmented tab picker (false for focused single-tab mode) /// Whether to show the segmented tab picker (false for focused single-tab mode)
@@ -160,6 +165,7 @@ struct QualitySelectorView: View {
localCaptionURL: URL? = nil, localCaptionURL: URL? = nil,
currentRate: PlaybackRate = .x1, currentRate: PlaybackRate = .x1,
isControlsLocked: Bool = false, isControlsLocked: Bool = false,
isAudioMode: Bool = false,
initialTab: QualitySelectorTab = .video, initialTab: QualitySelectorTab = .video,
showTabPicker: Bool = true, showTabPicker: Bool = true,
onStreamSelected: @escaping (Stream, Stream?) -> Void, onStreamSelected: @escaping (Stream, Stream?) -> Void,
@@ -168,6 +174,7 @@ struct QualitySelectorView: View {
onSwitchToOnlineStream: @escaping (Stream, Stream?) -> Void = { _, _ in }, onSwitchToOnlineStream: @escaping (Stream, Stream?) -> Void = { _, _ in },
onRateChanged: ((PlaybackRate) -> Void)? = nil, onRateChanged: ((PlaybackRate) -> Void)? = nil,
onLockToggled: ((Bool) -> Void)? = nil, onLockToggled: ((Bool) -> Void)? = nil,
onAudioModeToggled: ((Bool) -> Void)? = nil,
onDismiss: (() -> Void)? = nil onDismiss: (() -> Void)? = nil
) { ) {
self.streams = streams self.streams = streams
@@ -183,12 +190,14 @@ struct QualitySelectorView: View {
self.showTabPicker = showTabPicker self.showTabPicker = showTabPicker
self.currentRate = currentRate self.currentRate = currentRate
self.isControlsLocked = isControlsLocked self.isControlsLocked = isControlsLocked
self.isAudioMode = isAudioMode
self.onStreamSelected = onStreamSelected self.onStreamSelected = onStreamSelected
self.onCaptionSelected = onCaptionSelected self.onCaptionSelected = onCaptionSelected
self.onLoadOnlineStreams = onLoadOnlineStreams self.onLoadOnlineStreams = onLoadOnlineStreams
self.onSwitchToOnlineStream = onSwitchToOnlineStream self.onSwitchToOnlineStream = onSwitchToOnlineStream
self.onRateChanged = onRateChanged self.onRateChanged = onRateChanged
self.onLockToggled = onLockToggled self.onLockToggled = onLockToggled
self.onAudioModeToggled = onAudioModeToggled
self.onDismiss = onDismiss self.onDismiss = onDismiss
} }
@@ -291,7 +300,10 @@ struct QualitySelectorView: View {
} }
.onAppear { .onAppear {
selectedVideoStream = currentStream 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) #if os(tvOS)
// Defer until after the slide-in transition so the focus engine // Defer until after the slide-in transition so the focus engine
// has finished routing focus away from the (now hidden) player // has finished routing focus away from the (now hidden) player

View File

@@ -271,6 +271,19 @@ struct MacOSControlsSectionRenderer: View {
.opacity(isLocked ? 0.5 : 1.0) .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: case .seek:
if let settings = config.seekSettings { if let settings = config.seekSettings {
seekButton(settings: settings, config: config) seekButton(settings: settings, config: config)

View File

@@ -211,6 +211,7 @@ struct MacOSPlayerControlsView: View {
currentStream: nil, currentStream: nil,
currentAudioStream: nil, currentAudioStream: nil,
isAutoPlayNextEnabled: appEnvironment?.settingsManager.queueAutoPlayNext ?? true, isAutoPlayNextEnabled: appEnvironment?.settingsManager.queueAutoPlayNext ?? true,
isAudioModeEnabled: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false,
yatteeServerURL: yatteeServerURL, yatteeServerURL: yatteeServerURL,
deArrowBrandingProvider: appEnvironment?.deArrowBrandingProvider, deArrowBrandingProvider: appEnvironment?.deArrowBrandingProvider,
onClose: onClose, onClose: onClose,
@@ -225,6 +226,11 @@ struct MacOSPlayerControlsView: View {
onToggleAutoPlayNext: { [weak appEnvironment] in onToggleAutoPlayNext: { [weak appEnvironment] in
appEnvironment?.settingsManager.queueAutoPlayNext.toggle() 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, onShowSettings: onShowSettings,
onPlayNext: onPlayNext, onPlayNext: onPlayNext,
onPlayPrevious: onPlayPrevious, onPlayPrevious: onPlayPrevious,

View File

@@ -166,6 +166,7 @@ struct TVPlayerView: View {
return appEnvironment?.downloadManager.downloadsDirectory().appendingPathComponent(path) return appEnvironment?.downloadManager.downloadsDirectory().appendingPathComponent(path)
}, },
currentRate: playerState?.rate ?? .x1, currentRate: playerState?.rate ?? .x1,
isAudioMode: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false,
onStreamSelected: { stream, audioStream in onStreamSelected: { stream, audioStream in
switchToStream(stream, audioStream: audioStream) switchToStream(stream, audioStream: audioStream)
}, },
@@ -186,6 +187,11 @@ struct TVPlayerView: View {
playerState?.rate = rate playerState?.rate = rate
playerService.currentBackend?.rate = Float(rate.rawValue) playerService.currentBackend?.rate = Float(rate.rawValue)
}, },
onAudioModeToggled: { enabled in
Task {
await playerService.setAudioMode(enabled)
}
},
onDismiss: { hideQualitySheet() } onDismiss: { hideQualitySheet() }
) )
} }
@@ -606,18 +612,27 @@ struct TVPlayerView: View {
@ViewBuilder @ViewBuilder
private var videoLayer: some View { private var videoLayer: some View {
if let playerService, let isAudioOnly = playerState?.currentStream?.isAudioOnly == true
let backend = playerService.currentBackend as? MPVBackend, let hasBackend = playerService?.currentBackend is MPVBackend
let playerState {
MPVRenderViewRepresentable( ZStack {
backend: backend, if let playerService,
playerState: playerState let backend = playerService.currentBackend as? MPVBackend,
) let playerState {
.ignoresSafeArea() MPVRenderViewRepresentable(
.allowsHitTesting(false) backend: backend,
} else { playerState: playerState
// Fallback/loading state - show thumbnail )
if let video = playerState?.currentVideo, .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 { let thumbnailURL = video.bestThumbnail?.url {
AsyncImage(url: thumbnailURL) { image in AsyncImage(url: thumbnailURL) { image in
image image
@@ -735,12 +750,8 @@ struct TVPlayerView: View {
} }
private func switchToStream(_ stream: Stream, audioStream: Stream? = nil) { private func switchToStream(_ stream: Stream, audioStream: Stream? = nil) {
guard let video = playerState?.currentVideo else { return }
let currentTime = playerState?.currentTime
Task { Task {
await playerService?.play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime) await playerService?.selectStreamManually(stream, audioStream: audioStream)
} }
} }