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