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

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