Convert tvOS settings and queue overlays to half-screen panels

The Settings (quality/audio/subtitles) and Queue panels now slide in
from the right and occupy the right half of the screen, matching the
info/comments details panel introduced in 92cc8b79f. Video stays
visible on the left so the user retains visual context while browsing.

Both panels supply their own ultraThinMaterial backdrop and use a
custom title bar (replacing NavigationStack's auto-title on tvOS) so
the title styling and symmetric padding match across panels and
across pushed destination screens. The Menu button now pops the
quality panel's pushed Video/Audio/Subtitles detail screens before
dismissing the panel itself.

Removes the background Button from the focus tree while either panel
is open so D-pad left/right inside a row no longer escapes focus
into the player and triggers a seek. Initial focus is steered into
the first row programmatically since tvOS doesn't auto-focus inline
overlays the way it does for fullScreenCover.

Doubles the queue thumbnail size on tvOS (160x90) for readability at
the half-screen panel width.
This commit is contained in:
Arkadiusz Fal
2026-05-10 14:58:35 +02:00
parent 6e5714dd86
commit dac81e1ee8
5 changed files with 577 additions and 179 deletions

View File

@@ -123,18 +123,6 @@ struct TVPlayerView: View {
mpvPlayerContent
.ignoresSafeArea()
.playerToastOverlay()
// Quality / Settings selector (fullscreen cover gives tvOS enough room)
.fullScreenCover(isPresented: $showingQualitySheet) {
qualitySheetContent
}
.fullScreenCover(isPresented: $showingQueueSheet) {
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
QueueManagementSheet()
}
}
.fullScreenCover(isPresented: $showingErrorSheet) {
ZStack {
Rectangle()
@@ -148,65 +136,58 @@ struct TVPlayerView: View {
}
}
// MARK: - Quality Sheet Content
// MARK: - Quality Panel Content
/// Builds the quality/settings panel for the right-half overlay. The view
/// itself supplies its own glass backdrop (matches `TVDetailsPanel`).
@ViewBuilder
private var qualitySheetContent: some View {
private var qualityPanelContent: some View {
if let playerService {
let dashEnabled = appEnvironment?.settingsManager.dashEnabled ?? false
let supportedFormats = playerService.currentBackendType.supportedFormats
ZStack {
// Glass backdrop matches info/comments panel for visual uniformity
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
QualitySelectorView(
streams: playerService.availableStreams.filter { stream in
let format = StreamFormat.detect(from: stream)
if format == .dash && !dashEnabled {
return false
}
return supportedFormats.contains(format)
},
captions: playerService.availableCaptions,
currentStream: playerState?.currentStream,
currentAudioStream: playerState?.currentAudioStream,
currentCaption: playerService.currentCaption,
isLoading: playerState?.playbackState == .loading,
currentDownload: playerService.currentDownload,
isLoadingOnlineStreams: playerService.isLoadingOnlineStreams,
localCaptionURL: playerService.currentDownload.flatMap { download in
guard let path = download.localCaptionPath else { return nil }
return appEnvironment?.downloadManager.downloadsDirectory().appendingPathComponent(path)
},
currentRate: playerState?.rate ?? .x1,
onStreamSelected: { stream, audioStream in
switchToStream(stream, audioStream: audioStream)
},
onCaptionSelected: { caption in
playerService.loadCaption(caption)
},
onLoadOnlineStreams: {
Task {
await playerService.loadOnlineStreams()
}
},
onSwitchToOnlineStream: { stream, audioStream in
Task {
await playerService.switchToOnlineStream(stream, audioStream: audioStream)
}
},
onRateChanged: { rate in
playerState?.rate = rate
playerService.currentBackend?.rate = Float(rate.rawValue)
QualitySelectorView(
streams: playerService.availableStreams.filter { stream in
let format = StreamFormat.detect(from: stream)
if format == .dash && !dashEnabled {
return false
}
)
.frame(maxWidth: 900, maxHeight: 700)
.padding(.horizontal, 200)
.padding(.vertical, 80)
}
return supportedFormats.contains(format)
},
captions: playerService.availableCaptions,
currentStream: playerState?.currentStream,
currentAudioStream: playerState?.currentAudioStream,
currentCaption: playerService.currentCaption,
isLoading: playerState?.playbackState == .loading,
currentDownload: playerService.currentDownload,
isLoadingOnlineStreams: playerService.isLoadingOnlineStreams,
localCaptionURL: playerService.currentDownload.flatMap { download in
guard let path = download.localCaptionPath else { return nil }
return appEnvironment?.downloadManager.downloadsDirectory().appendingPathComponent(path)
},
currentRate: playerState?.rate ?? .x1,
onStreamSelected: { stream, audioStream in
switchToStream(stream, audioStream: audioStream)
},
onCaptionSelected: { caption in
playerService.loadCaption(caption)
},
onLoadOnlineStreams: {
Task {
await playerService.loadOnlineStreams()
}
},
onSwitchToOnlineStream: { stream, audioStream in
Task {
await playerService.switchToOnlineStream(stream, audioStream: audioStream)
}
},
onRateChanged: { rate in
playerState?.rate = rate
playerService.currentBackend?.rate = Float(rate.rawValue)
},
onDismiss: { hideQualitySheet() }
)
}
}
@@ -281,6 +262,38 @@ struct TVPlayerView: View {
.transition(.move(edge: .trailing).combined(with: .opacity))
}
// Right-side quality / settings panel (covers ~50% of screen)
if showingQualitySheet {
GeometryReader { geo in
HStack(spacing: 0) {
Spacer(minLength: 0)
qualityPanelContent
.frame(width: geo.size.width / 2)
// Ignore tvOS title-safe area on the trailing edge
// so panel content lines up symmetrically with the
// mid-screen leading edge instead of being inset
// from the physical right edge.
.ignoresSafeArea(.container, edges: .horizontal)
.focusSection()
}
}
.transition(.move(edge: .trailing).combined(with: .opacity))
}
// Right-side queue panel (covers ~50% of screen)
if showingQueueSheet {
GeometryReader { geo in
HStack(spacing: 0) {
Spacer(minLength: 0)
QueueManagementSheet(onDismiss: { hideQueueSheet() })
.frame(width: geo.size.width / 2)
.ignoresSafeArea(.container, edges: .horizontal)
.focusSection()
}
}
.transition(.move(edge: .trailing).combined(with: .opacity))
}
// Debug overlay
if isDebugOverlayVisible {
MPVDebugOverlay(
@@ -555,7 +568,12 @@ struct TVPlayerView: View {
@ViewBuilder
private var backgroundLayer: some View {
if !controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible && !isFailureOverlayVisible {
if !controlsVisible
&& !isDetailsPanelVisible
&& !isDebugOverlayVisible
&& !isFailureOverlayVisible
&& !showingQualitySheet
&& !showingQueueSheet {
// When controls hidden, use a Button to capture both click and swipe
Button {
showControls()
@@ -620,7 +638,12 @@ struct TVPlayerView: View {
/// Whether the primary controls overlay should be visible right now.
private var shouldShowControls: Bool {
controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible && !isFailureOverlayVisible
controlsVisible
&& !isDetailsPanelVisible
&& !isDebugOverlayVisible
&& !isFailureOverlayVisible
&& !showingQualitySheet
&& !showingQueueSheet
}
// MARK: - Controls Timer
@@ -674,12 +697,32 @@ struct TVPlayerView: View {
private func showQualitySheet() {
stopControlsTimer()
showingQualitySheet = true
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
showingQualitySheet = true
controlsVisible = false
}
}
private func hideQualitySheet() {
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
showingQualitySheet = false
}
showControls()
}
private func showQueueSheet() {
stopControlsTimer()
showingQueueSheet = true
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
showingQueueSheet = true
controlsVisible = false
}
}
private func hideQueueSheet() {
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
showingQueueSheet = false
}
showControls()
}
private func switchToStream(_ stream: Stream, audioStream: Stream? = nil) {
@@ -749,8 +792,12 @@ struct TVPlayerView: View {
return
}
// Show controls if hidden (but not if debug overlay is visible), then toggle playback
if !controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible {
// Show controls if hidden (but not if any overlay is visible), then toggle playback
if !controlsVisible
&& !isDetailsPanelVisible
&& !isDebugOverlayVisible
&& !showingQualitySheet
&& !showingQueueSheet {
showControls()
}
@@ -902,8 +949,14 @@ struct TVPlayerView: View {
} else if isDebugOverlayVisible {
// Second: hide debug overlay
hideDebugOverlay()
} else if showingQualitySheet {
// Third: hide quality / settings panel
hideQualitySheet()
} else if showingQueueSheet {
// Fourth: hide queue panel
hideQueueSheet()
} else if isDetailsPanelVisible {
// Third: hide details panel
// Fifth: hide details panel
hideDetailsPanel()
} else if isScrubbing {
// Fourth: cancel scrub without seeking, then hide controls. The