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

@@ -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)
}
}