mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user