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
|
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()
|
.padding()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
selectedTab = initialTab
|
selectedTab = initialTab
|
||||||
@@ -87,6 +96,7 @@ extension QualitySelectorView {
|
|||||||
systemImage: "film",
|
systemImage: "film",
|
||||||
value: currentVideoDisplayValue
|
value: currentVideoDisplayValue
|
||||||
)
|
)
|
||||||
|
.focused($inlinePanelInitialFocus)
|
||||||
if availableTabs.contains(.audio) {
|
if availableTabs.contains(.audio) {
|
||||||
mediaSelectionRow(
|
mediaSelectionRow(
|
||||||
destination: .audio,
|
destination: .audio,
|
||||||
@@ -195,64 +205,77 @@ extension QualitySelectorView {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var videoDetailContent: some View {
|
var videoDetailContent: some View {
|
||||||
ScrollView {
|
detailContent(title: String(localized: "player.quality.video")) {
|
||||||
VStack(spacing: 16) {
|
if !adaptiveStreams.isEmpty {
|
||||||
if !adaptiveStreams.isEmpty {
|
adaptiveSectionContent
|
||||||
adaptiveSectionContent
|
}
|
||||||
}
|
if !videoStreams.isEmpty {
|
||||||
if !videoStreams.isEmpty {
|
videoSectionContent
|
||||||
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
|
@ViewBuilder
|
||||||
var audioDetailContent: some View {
|
var audioDetailContent: some View {
|
||||||
ScrollView {
|
detailContent(title: String(localized: "stream.audio")) {
|
||||||
VStack(spacing: 16) {
|
audioSectionContent
|
||||||
audioSectionContent
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
|
||||||
.background(Color.clear)
|
|
||||||
#else
|
|
||||||
.background(ListBackgroundStyle.grouped.color)
|
|
||||||
#endif
|
|
||||||
.navigationTitle(String(localized: "stream.audio"))
|
|
||||||
#if os(iOS)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var subtitlesDetailContent: some View {
|
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 {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
subtitlesSectionContent
|
content()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
|
||||||
.background(Color.clear)
|
|
||||||
#else
|
|
||||||
.background(ListBackgroundStyle.grouped.color)
|
.background(ListBackgroundStyle.grouped.color)
|
||||||
#endif
|
.navigationTitle(title)
|
||||||
.navigationTitle(String(localized: "stream.subtitles"))
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -454,7 +477,7 @@ extension QualitySelectorView {
|
|||||||
isPreferred: false,
|
isPreferred: false,
|
||||||
onTap: {
|
onTap: {
|
||||||
onCaptionSelected(nil)
|
onCaptionSelected(nil)
|
||||||
dismiss()
|
performDismiss()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -467,7 +490,7 @@ extension QualitySelectorView {
|
|||||||
localCaptionURL: localCaptionURL,
|
localCaptionURL: localCaptionURL,
|
||||||
currentCaption: currentCaption,
|
currentCaption: currentCaption,
|
||||||
onCaptionSelected: onCaptionSelected,
|
onCaptionSelected: onCaptionSelected,
|
||||||
onDismiss: { dismiss() }
|
onDismiss: { performDismiss() }
|
||||||
)
|
)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -548,7 +571,7 @@ extension QualitySelectorView {
|
|||||||
} else {
|
} else {
|
||||||
onStreamSelected(stream, nil)
|
onStreamSelected(stream, nil)
|
||||||
}
|
}
|
||||||
dismiss()
|
performDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Video Section
|
// MARK: - Video Section
|
||||||
@@ -641,27 +664,27 @@ extension QualitySelectorView {
|
|||||||
if isDownloaded {
|
if isDownloaded {
|
||||||
if stream.isMuxed {
|
if stream.isMuxed {
|
||||||
onStreamSelected(stream, nil)
|
onStreamSelected(stream, nil)
|
||||||
dismiss()
|
performDismiss()
|
||||||
} else {
|
} else {
|
||||||
selectedVideoStream = stream
|
selectedVideoStream = stream
|
||||||
if let audio = selectedAudioStream {
|
if let audio = selectedAudioStream {
|
||||||
onStreamSelected(stream, audio)
|
onStreamSelected(stream, audio)
|
||||||
dismiss()
|
performDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if isPlayingDownloadedContent {
|
} else if isPlayingDownloadedContent {
|
||||||
let audioStream: Stream? = stream.isVideoOnly ? (selectedAudioStream ?? defaultAudioStream) : nil
|
let audioStream: Stream? = stream.isVideoOnly ? (selectedAudioStream ?? defaultAudioStream) : nil
|
||||||
onSwitchToOnlineStream(stream, audioStream)
|
onSwitchToOnlineStream(stream, audioStream)
|
||||||
dismiss()
|
performDismiss()
|
||||||
} else {
|
} else {
|
||||||
if stream.isMuxed {
|
if stream.isMuxed {
|
||||||
onStreamSelected(stream, nil)
|
onStreamSelected(stream, nil)
|
||||||
dismiss()
|
performDismiss()
|
||||||
} else {
|
} else {
|
||||||
selectedVideoStream = stream
|
selectedVideoStream = stream
|
||||||
if let audio = selectedAudioStream {
|
if let audio = selectedAudioStream {
|
||||||
onStreamSelected(stream, audio)
|
onStreamSelected(stream, audio)
|
||||||
dismiss()
|
performDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -730,7 +753,7 @@ extension QualitySelectorView {
|
|||||||
selectedAudioStream = stream
|
selectedAudioStream = stream
|
||||||
if let video = selectedVideoStream, video.isVideoOnly {
|
if let video = selectedVideoStream, video.isVideoOnly {
|
||||||
onStreamSelected(video, stream)
|
onStreamSelected(video, stream)
|
||||||
dismiss()
|
performDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,7 +826,7 @@ extension QualitySelectorView {
|
|||||||
} else {
|
} else {
|
||||||
onCaptionSelected(caption)
|
onCaptionSelected(caption)
|
||||||
}
|
}
|
||||||
dismiss()
|
performDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,21 @@ struct QualitySelectorView: View {
|
|||||||
/// Whether to show the segmented tab picker (false for focused single-tab mode)
|
/// Whether to show the segmented tab picker (false for focused single-tab mode)
|
||||||
var showTabPicker: Bool = true
|
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
|
// MARK: - State
|
||||||
@State var selectedTab: QualitySelectorTab = .video
|
@State var selectedTab: QualitySelectorTab = .video
|
||||||
@State var selectedVideoStream: Stream?
|
@State var selectedVideoStream: Stream?
|
||||||
@@ -152,7 +167,8 @@ struct QualitySelectorView: View {
|
|||||||
onLoadOnlineStreams: @escaping () -> Void = {},
|
onLoadOnlineStreams: @escaping () -> Void = {},
|
||||||
onSwitchToOnlineStream: @escaping (Stream, Stream?) -> Void = { _, _ in },
|
onSwitchToOnlineStream: @escaping (Stream, Stream?) -> Void = { _, _ in },
|
||||||
onRateChanged: ((PlaybackRate) -> Void)? = nil,
|
onRateChanged: ((PlaybackRate) -> Void)? = nil,
|
||||||
onLockToggled: ((Bool) -> Void)? = nil
|
onLockToggled: ((Bool) -> Void)? = nil,
|
||||||
|
onDismiss: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
self.streams = streams
|
self.streams = streams
|
||||||
self.captions = captions
|
self.captions = captions
|
||||||
@@ -173,67 +189,139 @@ struct QualitySelectorView: View {
|
|||||||
self.onSwitchToOnlineStream = onSwitchToOnlineStream
|
self.onSwitchToOnlineStream = onSwitchToOnlineStream
|
||||||
self.onRateChanged = onRateChanged
|
self.onRateChanged = onRateChanged
|
||||||
self.onLockToggled = onLockToggled
|
self.onLockToggled = onLockToggled
|
||||||
|
self.onDismiss = onDismiss
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Body
|
// 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 {
|
var body: some View {
|
||||||
NavigationStack {
|
#if os(tvOS)
|
||||||
Group {
|
NavigationStack(path: $navigationPath) {
|
||||||
if isLoading {
|
stackRoot
|
||||||
loadingContent
|
}
|
||||||
} else if isPlayingDownloadedContent {
|
.onExitCommand {
|
||||||
downloadedContent
|
// Pop the pushed detail view if present, otherwise dismiss the
|
||||||
} else if hasNoStreams {
|
// whole panel. (Without this, Menu always falls through to
|
||||||
emptyContent
|
// `performDismiss()` and closes the entire overlay even from a
|
||||||
} else {
|
// detail screen.)
|
||||||
streamsContent
|
if !navigationPath.isEmpty {
|
||||||
}
|
navigationPath.removeLast()
|
||||||
}
|
} else {
|
||||||
#if os(tvOS)
|
performDismiss()
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
NavigationStack {
|
||||||
|
stackRoot
|
||||||
|
}
|
||||||
.presentationDetents([.medium, .large])
|
.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,
|
cornerRadius: 6,
|
||||||
duration: queuedVideo.video.formattedDuration
|
duration: queuedVideo.video.formattedDuration
|
||||||
)
|
)
|
||||||
|
#if os(tvOS)
|
||||||
|
.frame(width: 160, height: 90)
|
||||||
|
#else
|
||||||
.frame(width: 80, height: 45)
|
.frame(width: 80, height: 45)
|
||||||
|
#endif
|
||||||
.opacity(isCurrentlyPlaying ? 0.6 : 1.0)
|
.opacity(isCurrentlyPlaying ? 0.6 : 1.0)
|
||||||
|
|
||||||
// Video info
|
// Video info
|
||||||
|
|||||||
@@ -11,6 +11,39 @@ struct QueueManagementSheet: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.appEnvironment) private var appEnvironment
|
@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 queueManager: QueueManager? { appEnvironment?.queueManager }
|
||||||
private var playerService: PlayerService? { appEnvironment?.playerService }
|
private var playerService: PlayerService? { appEnvironment?.playerService }
|
||||||
private var playerState: PlayerState? { playerService?.state }
|
private var playerState: PlayerState? { playerService?.state }
|
||||||
@@ -24,6 +57,58 @@ struct QueueManagementSheet: View {
|
|||||||
private var listStyle: VideoListStyle { appEnvironment?.settingsManager.listStyle ?? .inset }
|
private var listStyle: VideoListStyle { appEnvironment?.settingsManager.listStyle ?? .inset }
|
||||||
|
|
||||||
var body: some View {
|
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 {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if queue.isEmpty && history.isEmpty && playerState?.currentVideo == nil {
|
if queue.isEmpty && history.isEmpty && playerState?.currentVideo == nil {
|
||||||
@@ -40,17 +125,14 @@ struct QueueManagementSheet: View {
|
|||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
queueModeMenu
|
queueModeMenu
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button(role: .cancel) {
|
Button(role: .cancel) {
|
||||||
dismiss()
|
performDismiss()
|
||||||
} label: {
|
} label: {
|
||||||
Label(String(localized: "common.close"), systemImage: "xmark")
|
Label(String(localized: "common.close"), systemImage: "xmark")
|
||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
@@ -78,7 +160,133 @@ struct QueueManagementSheet: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
private var queueListView: some View {
|
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
|
ScrollViewReader { proxy in
|
||||||
List {
|
List {
|
||||||
// History section - show previously played videos
|
// History section - show previously played videos
|
||||||
@@ -94,6 +302,15 @@ struct QueueManagementSheet: View {
|
|||||||
playFromHistory(at: index)
|
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: {
|
} header: {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -122,6 +339,10 @@ struct QueueManagementSheet: View {
|
|||||||
onRemove: { },
|
onRemove: { },
|
||||||
onTap: { }
|
onTap: { }
|
||||||
)
|
)
|
||||||
|
#if os(tvOS)
|
||||||
|
.focused($inlinePanelFocus, equals: .nowPlaying)
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 40, bottom: 6, trailing: 40))
|
||||||
|
#endif
|
||||||
} header: {
|
} header: {
|
||||||
nowPlayingHeader
|
nowPlayingHeader
|
||||||
}
|
}
|
||||||
@@ -145,6 +366,15 @@ struct QueueManagementSheet: View {
|
|||||||
playVideo(at: index)
|
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
|
.onMove { source, destination in
|
||||||
guard let fromIndex = source.first else { return }
|
guard let fromIndex = source.first else { return }
|
||||||
@@ -290,7 +520,7 @@ struct QueueManagementSheet: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
|
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
|
||||||
dismiss()
|
performDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playFromHistory(at index: Int) {
|
private func playFromHistory(at index: Int) {
|
||||||
@@ -326,7 +556,7 @@ struct QueueManagementSheet: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
|
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
|
||||||
dismiss()
|
performDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,18 +123,6 @@ struct TVPlayerView: View {
|
|||||||
mpvPlayerContent
|
mpvPlayerContent
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.playerToastOverlay()
|
.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) {
|
.fullScreenCover(isPresented: $showingErrorSheet) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Rectangle()
|
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
|
@ViewBuilder
|
||||||
private var qualitySheetContent: some View {
|
private var qualityPanelContent: some View {
|
||||||
if let playerService {
|
if let playerService {
|
||||||
let dashEnabled = appEnvironment?.settingsManager.dashEnabled ?? false
|
let dashEnabled = appEnvironment?.settingsManager.dashEnabled ?? false
|
||||||
let supportedFormats = playerService.currentBackendType.supportedFormats
|
let supportedFormats = playerService.currentBackendType.supportedFormats
|
||||||
|
|
||||||
ZStack {
|
QualitySelectorView(
|
||||||
// Glass backdrop — matches info/comments panel for visual uniformity
|
streams: playerService.availableStreams.filter { stream in
|
||||||
Rectangle()
|
let format = StreamFormat.detect(from: stream)
|
||||||
.fill(.ultraThinMaterial)
|
if format == .dash && !dashEnabled {
|
||||||
.ignoresSafeArea()
|
return false
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
)
|
return supportedFormats.contains(format)
|
||||||
.frame(maxWidth: 900, maxHeight: 700)
|
},
|
||||||
.padding(.horizontal, 200)
|
captions: playerService.availableCaptions,
|
||||||
.padding(.vertical, 80)
|
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))
|
.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
|
// Debug overlay
|
||||||
if isDebugOverlayVisible {
|
if isDebugOverlayVisible {
|
||||||
MPVDebugOverlay(
|
MPVDebugOverlay(
|
||||||
@@ -555,7 +568,12 @@ struct TVPlayerView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var backgroundLayer: some View {
|
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
|
// When controls hidden, use a Button to capture both click and swipe
|
||||||
Button {
|
Button {
|
||||||
showControls()
|
showControls()
|
||||||
@@ -620,7 +638,12 @@ struct TVPlayerView: View {
|
|||||||
|
|
||||||
/// Whether the primary controls overlay should be visible right now.
|
/// Whether the primary controls overlay should be visible right now.
|
||||||
private var shouldShowControls: Bool {
|
private var shouldShowControls: Bool {
|
||||||
controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible && !isFailureOverlayVisible
|
controlsVisible
|
||||||
|
&& !isDetailsPanelVisible
|
||||||
|
&& !isDebugOverlayVisible
|
||||||
|
&& !isFailureOverlayVisible
|
||||||
|
&& !showingQualitySheet
|
||||||
|
&& !showingQueueSheet
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Controls Timer
|
// MARK: - Controls Timer
|
||||||
@@ -674,12 +697,32 @@ struct TVPlayerView: View {
|
|||||||
|
|
||||||
private func showQualitySheet() {
|
private func showQualitySheet() {
|
||||||
stopControlsTimer()
|
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() {
|
private func showQueueSheet() {
|
||||||
stopControlsTimer()
|
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) {
|
private func switchToStream(_ stream: Stream, audioStream: Stream? = nil) {
|
||||||
@@ -749,8 +792,12 @@ struct TVPlayerView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show controls if hidden (but not if debug overlay is visible), then toggle playback
|
// Show controls if hidden (but not if any overlay is visible), then toggle playback
|
||||||
if !controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible {
|
if !controlsVisible
|
||||||
|
&& !isDetailsPanelVisible
|
||||||
|
&& !isDebugOverlayVisible
|
||||||
|
&& !showingQualitySheet
|
||||||
|
&& !showingQueueSheet {
|
||||||
showControls()
|
showControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,8 +949,14 @@ struct TVPlayerView: View {
|
|||||||
} else if isDebugOverlayVisible {
|
} else if isDebugOverlayVisible {
|
||||||
// Second: hide debug overlay
|
// Second: hide debug overlay
|
||||||
hideDebugOverlay()
|
hideDebugOverlay()
|
||||||
|
} else if showingQualitySheet {
|
||||||
|
// Third: hide quality / settings panel
|
||||||
|
hideQualitySheet()
|
||||||
|
} else if showingQueueSheet {
|
||||||
|
// Fourth: hide queue panel
|
||||||
|
hideQueueSheet()
|
||||||
} else if isDetailsPanelVisible {
|
} else if isDetailsPanelVisible {
|
||||||
// Third: hide details panel
|
// Fifth: hide details panel
|
||||||
hideDetailsPanel()
|
hideDetailsPanel()
|
||||||
} else if isScrubbing {
|
} else if isScrubbing {
|
||||||
// Fourth: cancel scrub without seeking, then hide controls. The
|
// Fourth: cancel scrub without seeking, then hide controls. The
|
||||||
|
|||||||
Reference in New Issue
Block a user