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

@@ -66,7 +66,16 @@ extension QualitySelectorView {
generalSectionContent
}
}
#if os(tvOS)
// Explicit symmetric padding so rows are evenly inset from the
// half-screen panel's edges; matches the queue panel's 80pt
// breathing room so the focus halo around row buttons doesn't
// crowd the panel boundaries.
.padding(.horizontal, 80)
.padding(.vertical, 24)
#else
.padding()
#endif
}
.onAppear {
selectedTab = initialTab
@@ -87,6 +96,7 @@ extension QualitySelectorView {
systemImage: "film",
value: currentVideoDisplayValue
)
.focused($inlinePanelInitialFocus)
if availableTabs.contains(.audio) {
mediaSelectionRow(
destination: .audio,
@@ -195,64 +205,77 @@ extension QualitySelectorView {
@ViewBuilder
var videoDetailContent: some View {
ScrollView {
VStack(spacing: 16) {
if !adaptiveStreams.isEmpty {
adaptiveSectionContent
}
if !videoStreams.isEmpty {
videoSectionContent
}
detailContent(title: String(localized: "player.quality.video")) {
if !adaptiveStreams.isEmpty {
adaptiveSectionContent
}
if !videoStreams.isEmpty {
videoSectionContent
}
.padding()
}
#if os(tvOS)
.background(Color.clear)
#else
.background(ListBackgroundStyle.grouped.color)
#endif
.navigationTitle(String(localized: "player.quality.video"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
@ViewBuilder
var audioDetailContent: some View {
ScrollView {
VStack(spacing: 16) {
audioSectionContent
}
.padding()
detailContent(title: String(localized: "stream.audio")) {
audioSectionContent
}
#if os(tvOS)
.background(Color.clear)
#else
.background(ListBackgroundStyle.grouped.color)
#endif
.navigationTitle(String(localized: "stream.audio"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
@ViewBuilder
var subtitlesDetailContent: some View {
detailContent(title: String(localized: "stream.subtitles")) {
subtitlesSectionContent
}
}
/// Shared layout for the pushed detail screens. On tvOS the destination
/// gets the same glass backdrop + custom title bar as the panel root so
/// the visual treatment is continuous as the user navigates in.
@ViewBuilder
private func detailContent<Content: View>(
title: String,
@ViewBuilder content: () -> Content
) -> some View {
#if os(tvOS)
VStack(alignment: .leading, spacing: 0) {
ZStack {
Text(title)
.font(.system(size: 32, weight: .semibold))
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 80)
.padding(.top, 32)
.padding(.bottom, 16)
ScrollView {
VStack(spacing: 16) {
content()
}
.padding(.horizontal, 80)
.padding(.vertical, 24)
}
}
.background(
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
)
.ignoresSafeArea(.container, edges: .horizontal)
#else
ScrollView {
VStack(spacing: 16) {
subtitlesSectionContent
content()
}
.padding()
}
#if os(tvOS)
.background(Color.clear)
#else
.background(ListBackgroundStyle.grouped.color)
#endif
.navigationTitle(String(localized: "stream.subtitles"))
.navigationTitle(title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#endif
}
@ViewBuilder
@@ -454,7 +477,7 @@ extension QualitySelectorView {
isPreferred: false,
onTap: {
onCaptionSelected(nil)
dismiss()
performDismiss()
}
)
.padding(.vertical, 8)
@@ -467,7 +490,7 @@ extension QualitySelectorView {
localCaptionURL: localCaptionURL,
currentCaption: currentCaption,
onCaptionSelected: onCaptionSelected,
onDismiss: { dismiss() }
onDismiss: { performDismiss() }
)
.padding(.vertical, 8)
.padding(.horizontal, 12)
@@ -548,7 +571,7 @@ extension QualitySelectorView {
} else {
onStreamSelected(stream, nil)
}
dismiss()
performDismiss()
}
// MARK: - Video Section
@@ -641,27 +664,27 @@ extension QualitySelectorView {
if isDownloaded {
if stream.isMuxed {
onStreamSelected(stream, nil)
dismiss()
performDismiss()
} else {
selectedVideoStream = stream
if let audio = selectedAudioStream {
onStreamSelected(stream, audio)
dismiss()
performDismiss()
}
}
} else if isPlayingDownloadedContent {
let audioStream: Stream? = stream.isVideoOnly ? (selectedAudioStream ?? defaultAudioStream) : nil
onSwitchToOnlineStream(stream, audioStream)
dismiss()
performDismiss()
} else {
if stream.isMuxed {
onStreamSelected(stream, nil)
dismiss()
performDismiss()
} else {
selectedVideoStream = stream
if let audio = selectedAudioStream {
onStreamSelected(stream, audio)
dismiss()
performDismiss()
}
}
}
@@ -730,7 +753,7 @@ extension QualitySelectorView {
selectedAudioStream = stream
if let video = selectedVideoStream, video.isVideoOnly {
onStreamSelected(video, stream)
dismiss()
performDismiss()
}
}
@@ -803,7 +826,7 @@ extension QualitySelectorView {
} else {
onCaptionSelected(caption)
}
dismiss()
performDismiss()
}
}

View File

@@ -44,6 +44,21 @@ struct QualitySelectorView: View {
/// Whether to show the segmented tab picker (false for focused single-tab mode)
var showTabPicker: Bool = true
/// Optional dismiss callback used when the view is presented inline (e.g. as
/// the tvOS half-screen panel). When nil, falls back to `@Environment(\.dismiss)`.
var onDismiss: (() -> Void)?
#if os(tvOS)
/// Bound to the first focusable row so we can programmatically pull focus
/// into the panel on appear (the system doesn't auto-focus an inline
/// overlay the way it does for `fullScreenCover`).
@FocusState var inlinePanelInitialFocus: Bool
/// Tracks pushed destinations so the Menu-button handler can pop instead
/// of dismissing when the user has navigated into a detail screen.
@State private var navigationPath = NavigationPath()
#endif
// MARK: - State
@State var selectedTab: QualitySelectorTab = .video
@State var selectedVideoStream: Stream?
@@ -152,7 +167,8 @@ struct QualitySelectorView: View {
onLoadOnlineStreams: @escaping () -> Void = {},
onSwitchToOnlineStream: @escaping (Stream, Stream?) -> Void = { _, _ in },
onRateChanged: ((PlaybackRate) -> Void)? = nil,
onLockToggled: ((Bool) -> Void)? = nil
onLockToggled: ((Bool) -> Void)? = nil,
onDismiss: (() -> Void)? = nil
) {
self.streams = streams
self.captions = captions
@@ -173,67 +189,139 @@ struct QualitySelectorView: View {
self.onSwitchToOnlineStream = onSwitchToOnlineStream
self.onRateChanged = onRateChanged
self.onLockToggled = onLockToggled
self.onDismiss = onDismiss
}
// MARK: - Body
@ViewBuilder
private var rootContent: some View {
if isLoading {
loadingContent
} else if isPlayingDownloadedContent {
downloadedContent
} else if hasNoStreams {
emptyContent
} else {
streamsContent
}
}
var body: some View {
NavigationStack {
Group {
if isLoading {
loadingContent
} else if isPlayingDownloadedContent {
downloadedContent
} else if hasNoStreams {
emptyContent
} else {
streamsContent
}
}
#if os(tvOS)
// On tvOS the quality sheet is presented over the player with an outer
// ultraThinMaterial backdrop (see TVPlayerView.qualitySheetContent), so
// the list itself must be transparent to let the glass show through.
.background(Color.clear)
#else
.background(ListBackgroundStyle.grouped.color)
#endif
.navigationTitle(navigationTitle)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#if os(tvOS)
// Menu at the root dismisses; pushed detail views use NavigationStack's
// default pop-on-Menu behavior and won't hit this handler.
.onExitCommand { dismiss() }
#else
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(role: .cancel) {
dismiss()
} label: {
Label(String(localized: "common.close"), systemImage: "xmark")
.labelStyle(.iconOnly)
}
}
}
#endif
.navigationDestination(for: QualitySelectorDestination.self) { destination in
switch destination {
case .video:
videoDetailContent
case .audio:
audioDetailContent
case .subtitles:
subtitlesDetailContent
}
}
.onAppear {
selectedVideoStream = currentStream
selectedAudioStream = currentAudioStream ?? defaultAudioStream
#if os(tvOS)
NavigationStack(path: $navigationPath) {
stackRoot
}
.onExitCommand {
// Pop the pushed detail view if present, otherwise dismiss the
// whole panel. (Without this, Menu always falls through to
// `performDismiss()` and closes the entire overlay even from a
// detail screen.)
if !navigationPath.isEmpty {
navigationPath.removeLast()
} else {
performDismiss()
}
}
#else
NavigationStack {
stackRoot
}
.presentationDetents([.medium, .large])
#endif
}
@ViewBuilder
private var stackRoot: some View {
Group {
#if os(tvOS)
// Custom title bar matches the queue panel's style.
VStack(alignment: .leading, spacing: 0) {
ZStack {
Text(navigationTitle)
.font(.system(size: 32, weight: .semibold))
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 80)
.padding(.top, 32)
.padding(.bottom, 16)
rootContent
}
#else
rootContent
#endif
}
#if os(tvOS)
// On tvOS the panel is presented inline as a half-screen overlay; it
// supplies its own glass backdrop so the underlying video stays partly
// visible while the menu is readable on top of the bright frame.
.background(panelGlassBackground)
// Disable the title-safe-area inset that NavigationStack would
// otherwise apply on the trailing edge (because the panel sits at
// the physical right edge of the screen).
.ignoresSafeArea(.container, edges: .horizontal)
#else
.background(ListBackgroundStyle.grouped.color)
.navigationTitle(navigationTitle)
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#if !os(tvOS)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(role: .cancel) {
performDismiss()
} label: {
Label(String(localized: "common.close"), systemImage: "xmark")
.labelStyle(.iconOnly)
}
}
}
#endif
.navigationDestination(for: QualitySelectorDestination.self) { destination in
switch destination {
case .video:
videoDetailContent
case .audio:
audioDetailContent
case .subtitles:
subtitlesDetailContent
}
}
.onAppear {
selectedVideoStream = currentStream
selectedAudioStream = 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
// controls, then pull focus into the first row.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
inlinePanelInitialFocus = true
}
#endif
}
}
#if os(tvOS)
/// Reusable ultraThinMaterial backdrop applied to the panel root and to
/// each pushed destination view so the glass remains continuous as the
/// user navigates into Video / Audio / Subtitles detail screens.
private var panelGlassBackground: some View {
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
}
#endif
func performDismiss() {
if let onDismiss {
onDismiss()
} else {
dismiss()
}
}
}

View File

@@ -45,7 +45,11 @@ struct QueueItemRow: View {
cornerRadius: 6,
duration: queuedVideo.video.formattedDuration
)
#if os(tvOS)
.frame(width: 160, height: 90)
#else
.frame(width: 80, height: 45)
#endif
.opacity(isCurrentlyPlaying ? 0.6 : 1.0)
// Video info

View File

@@ -11,6 +11,39 @@ struct QueueManagementSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
/// Optional dismiss callback used when the view is presented inline (e.g. as
/// the tvOS half-screen panel) rather than via a sheet/fullScreenCover. When
/// nil, falls back to `@Environment(\.dismiss)`.
var onDismiss: (() -> Void)?
#if os(tvOS)
/// Identifies which row should receive focus when the inline panel appears.
/// Necessary because tvOS doesn't auto-focus an inline overlay the way it
/// does for `fullScreenCover`.
enum InlinePanelFocus: Hashable {
case history(String)
case nowPlaying
case upNext(String)
}
@FocusState var inlinePanelFocus: InlinePanelFocus?
private var inlinePanelInitialFocusTarget: InlinePanelFocus? {
if let first = history.first { return .history(first.id) }
if playerState?.currentVideo != nil { return .nowPlaying }
if let first = queue.first { return .upNext(first.id) }
return nil
}
#endif
private func performDismiss() {
if let onDismiss {
onDismiss()
} else {
dismiss()
}
}
private var queueManager: QueueManager? { appEnvironment?.queueManager }
private var playerService: PlayerService? { appEnvironment?.playerService }
private var playerState: PlayerState? { playerService?.state }
@@ -24,6 +57,58 @@ struct QueueManagementSheet: View {
private var listStyle: VideoListStyle { appEnvironment?.settingsManager.listStyle ?? .inset }
var body: some View {
#if os(tvOS)
tvOSPanelBody
#else
nonTVOSBody
#endif
}
#if os(tvOS)
/// Custom layout for the tvOS half-screen inline panel. Bypasses
/// `NavigationStack` entirely because NavigationStack on tvOS reserves an
/// asymmetric content area that can't be escaped via `.ignoresSafeArea`
/// from the inside.
private var tvOSPanelBody: some View {
VStack(alignment: .leading, spacing: 0) {
// Custom title bar replaces the NavigationStack toolbar. Uses
// ZStack so the centered title is decoupled from the leading
// shuffle button's vertical sizing.
ZStack {
Text(queueSourceLabel ?? String(localized: "queue.sheet.title"))
.font(.system(size: 32, weight: .semibold))
.foregroundStyle(.primary)
HStack {
queueModeMenu
Spacer()
}
}
.padding(.horizontal, 80)
.padding(.top, 32)
.padding(.bottom, 16)
if queue.isEmpty && history.isEmpty && playerState?.currentVideo == nil {
emptyStateView
} else {
tvOSQueueListView
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
)
.onExitCommand { performDismiss() }
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
inlinePanelFocus = inlinePanelInitialFocusTarget
}
}
}
#endif
private var nonTVOSBody: some View {
NavigationStack {
Group {
if queue.isEmpty && history.isEmpty && playerState?.currentVideo == nil {
@@ -40,17 +125,14 @@ struct QueueManagementSheet: View {
ToolbarItem(placement: .cancellationAction) {
queueModeMenu
}
#if !os(tvOS)
ToolbarItem(placement: .confirmationAction) {
Button(role: .cancel) {
dismiss()
performDismiss()
} label: {
Label(String(localized: "common.close"), systemImage: "xmark")
.labelStyle(.iconOnly)
}
}
#endif
}
}
.presentationDragIndicator(.visible)
@@ -78,7 +160,133 @@ struct QueueManagementSheet: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var queueListView: some View {
#if os(tvOS)
// On tvOS the panel uses a ScrollView+VStack instead of a `List` because
// `.listStyle(.grouped)` introduces an asymmetric leading inset that
// can't be overridden via `.listRowInsets` the inset is structural to
// the grouped style. Custom layout gives us full control over symmetric
// padding inside the half-screen panel.
tvOSQueueListView
#else
iosQueueListView
#endif
}
#if os(tvOS)
private var tvOSQueueListView: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 24) {
if !history.isEmpty {
sectionHeader(
String(localized: "queue.section.previously"),
count: history.count
)
VStack(spacing: 8) {
ForEach(Array(history.enumerated()), id: \.element.id) { index, historyItem in
QueueItemRow(
queuedVideo: historyItem,
index: nil,
isCurrentlyPlaying: false,
onRemove: { },
onTap: { playFromHistory(at: index) }
)
.focused($inlinePanelFocus, equals: .history(historyItem.id))
}
}
}
if let currentVideo = playerState?.currentVideo {
sectionHeader(String(localized: "queue.section.nowPlaying"))
.id("now-playing")
let nowPlayingItem = QueuedVideo(
video: currentVideo,
stream: playerState?.currentStream,
queueSource: nil
)
QueueItemRow(
queuedVideo: nowPlayingItem,
index: nil,
isCurrentlyPlaying: true,
onRemove: { },
onTap: { }
)
.focused($inlinePanelFocus, equals: .nowPlaying)
}
if !queue.isEmpty {
sectionHeader(
String(localized: "queue.section.upNext"),
count: queue.count
)
VStack(spacing: 8) {
ForEach(Array(queue.enumerated()), id: \.element.id) { index, queuedVideo in
QueueItemRow(
queuedVideo: queuedVideo,
index: index + 1,
isCurrentlyPlaying: false,
onRemove: {
withAnimation {
queueManager?.removeFromQueue(id: queuedVideo.id)
}
},
onTap: { playVideo(at: index) }
)
.focused($inlinePanelFocus, equals: .upNext(queuedVideo.id))
}
if hasMoreItems {
HStack {
Spacer()
if isLoadingMore {
ProgressView().controlSize(.small)
} else {
Button {
Task { try? await queueManager?.loadMoreQueueItems() }
} label: {
Text(String(localized: "queue.sheet.loadMore"))
.font(.subheadline)
.foregroundStyle(.tint)
}
}
Spacer()
}
.padding(.top, 12)
}
}
}
}
.padding(.horizontal, 80)
.padding(.vertical, 24)
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
proxy.scrollTo("now-playing", anchor: .center)
}
}
}
}
@ViewBuilder
private func sectionHeader(_ title: String, count: Int? = nil) -> some View {
HStack {
Text(title.uppercased())
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if let count {
Text(String(localized: "queue.section.count \(count)"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
#endif
private var iosQueueListView: some View {
ScrollViewReader { proxy in
List {
// History section - show previously played videos
@@ -94,6 +302,15 @@ struct QueueManagementSheet: View {
playFromHistory(at: index)
}
)
#if os(tvOS)
.focused($inlinePanelFocus, equals: .history(historyItem.id))
// Zero the grouped style's built-in row insets
// (which are asymmetric in a half-screen panel) and
// apply our own symmetric padding directly to the
// row content.
.padding(.horizontal, 40)
.listRowInsets(EdgeInsets())
#endif
}
} header: {
HStack {
@@ -122,6 +339,10 @@ struct QueueManagementSheet: View {
onRemove: { },
onTap: { }
)
#if os(tvOS)
.focused($inlinePanelFocus, equals: .nowPlaying)
.listRowInsets(EdgeInsets(top: 6, leading: 40, bottom: 6, trailing: 40))
#endif
} header: {
nowPlayingHeader
}
@@ -145,6 +366,15 @@ struct QueueManagementSheet: View {
playVideo(at: index)
}
)
#if os(tvOS)
.focused($inlinePanelFocus, equals: .upNext(queuedVideo.id))
// Zero the grouped style's built-in row insets
// (which are asymmetric in a half-screen panel) and
// apply our own symmetric padding directly to the
// row content.
.padding(.horizontal, 40)
.listRowInsets(EdgeInsets())
#endif
}
.onMove { source, destination in
guard let fromIndex = source.first else { return }
@@ -290,7 +520,7 @@ struct QueueManagementSheet: View {
)
}
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
dismiss()
performDismiss()
}
private func playFromHistory(at index: Int) {
@@ -326,7 +556,7 @@ struct QueueManagementSheet: View {
)
}
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
dismiss()
performDismiss()
}
}

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