mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user