diff --git a/Yattee/Views/Player/QualitySelectorView+Sections.swift b/Yattee/Views/Player/QualitySelectorView+Sections.swift index 567afecc..f2aaede0 100644 --- a/Yattee/Views/Player/QualitySelectorView+Sections.swift +++ b/Yattee/Views/Player/QualitySelectorView+Sections.swift @@ -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( + 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() } } diff --git a/Yattee/Views/Player/QualitySelectorView.swift b/Yattee/Views/Player/QualitySelectorView.swift index 9cd058d5..4da45290 100644 --- a/Yattee/Views/Player/QualitySelectorView.swift +++ b/Yattee/Views/Player/QualitySelectorView.swift @@ -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() + } } } diff --git a/Yattee/Views/Player/QueueItemRow.swift b/Yattee/Views/Player/QueueItemRow.swift index 9fc2f8c9..e41a1437 100644 --- a/Yattee/Views/Player/QueueItemRow.swift +++ b/Yattee/Views/Player/QueueItemRow.swift @@ -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 diff --git a/Yattee/Views/Player/QueueManagementSheet.swift b/Yattee/Views/Player/QueueManagementSheet.swift index ffad4890..04590c66 100644 --- a/Yattee/Views/Player/QueueManagementSheet.swift +++ b/Yattee/Views/Player/QueueManagementSheet.swift @@ -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() } } diff --git a/Yattee/Views/Player/tvOS/TVPlayerView.swift b/Yattee/Views/Player/tvOS/TVPlayerView.swift index 9a893374..b26c0403 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerView.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerView.swift @@ -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