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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" : {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,43 +2114,10 @@ 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
|
|
||||||
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)
|
return (bestVideo, audio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to muxed streams - prefer progressive formats over HLS/DASH for non-live content
|
// Fallback to muxed streams - prefer progressive formats over HLS/DASH for non-live content
|
||||||
let filteredMuxed: [Stream]
|
let filteredMuxed: [Stream]
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
VStack(spacing: 8) {
|
||||||
playbackSpeedRow
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +612,10 @@ struct TVPlayerView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var videoLayer: some View {
|
private var videoLayer: some View {
|
||||||
|
let isAudioOnly = playerState?.currentStream?.isAudioOnly == true
|
||||||
|
let hasBackend = playerService?.currentBackend is MPVBackend
|
||||||
|
|
||||||
|
ZStack {
|
||||||
if let playerService,
|
if let playerService,
|
||||||
let backend = playerService.currentBackend as? MPVBackend,
|
let backend = playerService.currentBackend as? MPVBackend,
|
||||||
let playerState {
|
let playerState {
|
||||||
@@ -615,9 +625,14 @@ struct TVPlayerView: View {
|
|||||||
)
|
)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
} else {
|
// Keep the render view alive but hidden for audio-only
|
||||||
// Fallback/loading state - show thumbnail
|
// playback so toggling audio mode doesn't tear down GL state
|
||||||
if let video = playerState?.currentVideo,
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user