diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index 60d1dfed..ca15e088 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -93,6 +93,7 @@ enum SettingsKey: String, CaseIterable { // Advanced case showAdvancedStreamDetails case showPlayerAreaDebug + case showTVDebugButton case verboseMPVLogging case verboseRemoteControlLogging case mpvBufferSeconds diff --git a/Yattee/Core/Settings/SettingsManager+Advanced.swift b/Yattee/Core/Settings/SettingsManager+Advanced.swift index 25148fdd..eb03971e 100644 --- a/Yattee/Core/Settings/SettingsManager+Advanced.swift +++ b/Yattee/Core/Settings/SettingsManager+Advanced.swift @@ -39,6 +39,19 @@ extension SettingsManager { } } + /// Whether to show the Debug button in the tvOS player bottom controls. + /// Opens the MPV debug overlay. Default is false (hidden). + var showTVDebugButton: Bool { + get { + if let cached = _showTVDebugButton { return cached } + return bool(for: .showTVDebugButton, default: false) + } + set { + _showTVDebugButton = newValue + set(newValue, for: .showTVDebugButton) + } + } + /// Whether verbose MPV rendering logging is enabled. /// When enabled, logs detailed OpenGL context, framebuffer, and display link state /// to help diagnose rendering issues. Default is false (disabled). diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index f625fe59..ddea5809 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -140,6 +140,7 @@ final class SettingsManager { // Advanced settings var _showAdvancedStreamDetails: Bool? var _showPlayerAreaDebug: Bool? + var _showTVDebugButton: Bool? var _verboseMPVLogging: Bool? var _verboseRemoteControlLogging: Bool? var _mpvBufferSeconds: Double? @@ -465,6 +466,7 @@ final class SettingsManager { _sidebarPlaylistsLimitEnabled = nil _showAdvancedStreamDetails = nil _showPlayerAreaDebug = nil + _showTVDebugButton = nil _verboseMPVLogging = nil _verboseRemoteControlLogging = nil _mpvBufferSeconds = nil diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 222b34a0..325388a8 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -6523,6 +6523,26 @@ } } }, + "player.controls.close" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + } + } + }, + "player.controls.comments" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comments" + } + } + } + }, "player.controls.info" : { "localizations" : { "en" : { @@ -6534,6 +6554,7 @@ } }, "player.controls.quality" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -6543,7 +6564,18 @@ } } }, + "player.controls.settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, "player.controls.subtitles" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -7052,6 +7084,7 @@ } }, "player.tvos.volumeDown" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -7062,6 +7095,7 @@ } }, "player.tvos.volumeUp" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -8693,6 +8727,17 @@ } } }, + "settings.advanced.debug.showTVDebugButton" : { + "comment" : "Toggle label (tvOS only)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Debug Button in Player" + } + } + } + }, "settings.advanced.debug.verboseMPV" : { "comment" : "Toggle label", "localizations" : { diff --git a/Yattee/Views/Player/QualitySelector/QualitySelectorRowViews.swift b/Yattee/Views/Player/QualitySelector/QualitySelectorRowViews.swift index 3ac4497a..b8f0ad89 100644 --- a/Yattee/Views/Player/QualitySelector/QualitySelectorRowViews.swift +++ b/Yattee/Views/Player/QualitySelector/QualitySelectorRowViews.swift @@ -7,6 +7,13 @@ import SwiftUI +#if os(tvOS) +/// Padding used inside selection row buttons so the tvOS focus background +/// fills the whole row. +private let tvRowVerticalPadding: CGFloat = 14 +private let tvRowHorizontalPadding: CGFloat = 20 +#endif + // MARK: - Adaptive Stream Row /// Row view for HLS/DASH adaptive streams. @@ -61,9 +68,18 @@ struct AdaptiveStreamRowView: View { .foregroundStyle(.tint) } } + #if os(tvOS) + .padding(.vertical, tvRowVerticalPadding) + .padding(.horizontal, tvRowHorizontalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + #endif .contentShape(Rectangle()) } + #if os(tvOS) + .buttonStyle(TVSettingsRowButtonStyle()) + #else .buttonStyle(.plain) + #endif } } @@ -97,9 +113,18 @@ struct VideoStreamRowView: View { } } .frame(minHeight: showAdvancedDetails ? nil : 36) + #if os(tvOS) + .padding(.vertical, tvRowVerticalPadding) + .padding(.horizontal, tvRowHorizontalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + #endif .contentShape(Rectangle()) } + #if os(tvOS) + .buttonStyle(TVSettingsRowButtonStyle()) + #else .buttonStyle(.plain) + #endif } @ViewBuilder @@ -251,9 +276,18 @@ struct AudioStreamRowView: View { } } .frame(minHeight: showAdvancedDetails ? nil : 36) + #if os(tvOS) + .padding(.vertical, tvRowVerticalPadding) + .padding(.horizontal, tvRowHorizontalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + #endif .contentShape(Rectangle()) } + #if os(tvOS) + .buttonStyle(TVSettingsRowButtonStyle()) + #else .buttonStyle(.plain) + #endif } @ViewBuilder @@ -341,9 +375,18 @@ struct CaptionRowView: View { } } .frame(minHeight: 36) + #if os(tvOS) + .padding(.vertical, tvRowVerticalPadding) + .padding(.horizontal, tvRowHorizontalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + #endif .contentShape(Rectangle()) } + #if os(tvOS) + .buttonStyle(TVSettingsRowButtonStyle()) + #else .buttonStyle(.plain) + #endif } @ViewBuilder diff --git a/Yattee/Views/Player/QualitySelectorView+Sections.swift b/Yattee/Views/Player/QualitySelectorView+Sections.swift index 701ce2e4..22baa1e6 100644 --- a/Yattee/Views/Player/QualitySelectorView+Sections.swift +++ b/Yattee/Views/Player/QualitySelectorView+Sections.swift @@ -77,65 +77,91 @@ extension QualitySelectorView { @ViewBuilder private var mediaSelectionRows: some View { - VStack(spacing: 0) { - NavigationLink(value: QualitySelectorDestination.video) { - HStack { - Label(String(localized: "player.quality.video"), systemImage: "film") - .font(.headline) - Spacer() - Text(currentVideoDisplayValue) - .foregroundStyle(.secondary) - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .padding(.vertical, 12) - .padding(.horizontal, 12) - .contentShape(Rectangle()) + #if os(tvOS) + // tvOS: each row is its own rounded card with vertical spacing so the + // system focus hover effect has clean bounds (no clipped dividers). + VStack(spacing: 8) { + mediaSelectionRow( + destination: .video, + label: String(localized: "player.quality.video"), + systemImage: "film", + value: currentVideoDisplayValue + ) + if availableTabs.contains(.audio) { + mediaSelectionRow( + destination: .audio, + label: String(localized: "stream.audio"), + systemImage: "speaker.wave.2", + value: currentAudioDisplayValue + ) } - .buttonStyle(.plain) - + if availableTabs.contains(.subtitles) { + mediaSelectionRow( + destination: .subtitles, + label: String(localized: "stream.subtitles"), + systemImage: "captions.bubble", + value: currentSubtitlesDisplayValue + ) + } + } + #else + VStack(spacing: 0) { + mediaSelectionRow( + destination: .video, + label: String(localized: "player.quality.video"), + systemImage: "film", + value: currentVideoDisplayValue + ) if availableTabs.contains(.audio) { Divider() - NavigationLink(value: QualitySelectorDestination.audio) { - HStack { - Label(String(localized: "stream.audio"), systemImage: "speaker.wave.2") - .font(.headline) - Spacer() - Text(currentAudioDisplayValue) - .foregroundStyle(.secondary) - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .padding(.vertical, 12) - .padding(.horizontal, 12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + mediaSelectionRow( + destination: .audio, + label: String(localized: "stream.audio"), + systemImage: "speaker.wave.2", + value: currentAudioDisplayValue + ) } - if availableTabs.contains(.subtitles) { Divider() - NavigationLink(value: QualitySelectorDestination.subtitles) { - HStack { - Label(String(localized: "stream.subtitles"), systemImage: "captions.bubble") - .font(.headline) - Spacer() - Text(currentSubtitlesDisplayValue) - .foregroundStyle(.secondary) - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .padding(.vertical, 12) - .padding(.horizontal, 12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + mediaSelectionRow( + destination: .subtitles, + label: String(localized: "stream.subtitles"), + systemImage: "captions.bubble", + value: currentSubtitlesDisplayValue + ) } } .cardBackground() + #endif + } + + @ViewBuilder + private func mediaSelectionRow( + destination: QualitySelectorDestination, + label: String, + systemImage: String, + value: String + ) -> some View { + NavigationLink(value: destination) { + HStack { + Label(label, systemImage: systemImage) + .font(.headline) + Spacer() + Text(value) + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 12) + .padding(.horizontal, 20) + .contentShape(Rectangle()) + } + #if os(tvOS) + .buttonStyle(TVSettingsRowButtonStyle()) + #else + .buttonStyle(.plain) + #endif } // MARK: - Display Value Computed Properties @@ -261,16 +287,19 @@ extension QualitySelectorView { @ViewBuilder var generalSectionContent: some View { + #if os(tvOS) + // tvOS only has the speed row here; style it to match the Settings rows. + playbackSpeedRow + #else VStack(spacing: 0) { playbackSpeedRow Divider() - #if !os(tvOS) lockControlsRow - #endif } .cardBackground() + #endif } @ViewBuilder @@ -310,9 +339,15 @@ extension QualitySelectorView { } label: { Text(currentRate.displayText) .font(.body.weight(.medium)) - .frame(minWidth: 60) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: 80) } + #if os(tvOS) + // Default menu style on tvOS renders a focusable bordered pill. + #else .menuStyle(.borderlessButton) + #endif Button { if let newRate = nextRate() { @@ -328,8 +363,18 @@ extension QualitySelectorView { .disabled(nextRate() == nil) } } + #if os(tvOS) + .padding(.vertical, 14) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.white.opacity(0.08)) + ) + #else .padding(.vertical, 12) .padding(.horizontal, 12) + #endif } #if !os(tvOS) @@ -455,6 +500,19 @@ extension QualitySelectorView { .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) + #if os(tvOS) + VStack(spacing: 8) { + ForEach(adaptiveStreams, id: \.url) { stream in + AdaptiveStreamRowView( + stream: stream, + isSelected: stream.url == currentStream?.url, + onTap: { + handleAdaptiveStreamTap(stream) + } + ) + } + } + #else VStack(spacing: 0) { ForEach(Array(adaptiveStreams.enumerated()), id: \.element.url) { index, stream in if index > 0 { @@ -472,6 +530,7 @@ extension QualitySelectorView { } } .cardBackground() + #endif } } @@ -496,6 +555,13 @@ extension QualitySelectorView { .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) + #if os(tvOS) + VStack(spacing: 8) { + ForEach(recommendedVideoStreams, id: \.url) { stream in + videoStreamRow(stream) + } + } + #else VStack(spacing: 0) { ForEach(Array(recommendedVideoStreams.enumerated()), id: \.element.url) { index, stream in if index > 0 { @@ -507,6 +573,7 @@ extension QualitySelectorView { } } .cardBackground() + #endif } } @@ -517,6 +584,13 @@ extension QualitySelectorView { .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) + #if os(tvOS) + VStack(spacing: 8) { + ForEach(otherVideoStreams, id: \.url) { stream in + videoStreamRow(stream) + } + } + #else VStack(spacing: 0) { ForEach(Array(otherVideoStreams.enumerated()), id: \.element.url) { index, stream in if index > 0 { @@ -528,6 +602,7 @@ extension QualitySelectorView { } } .cardBackground() + #endif } } } @@ -603,6 +678,13 @@ extension QualitySelectorView { } .cardBackground() } else { + #if os(tvOS) + VStack(spacing: 8) { + ForEach(audioStreams, id: \.url) { stream in + audioStreamRow(stream) + } + } + #else VStack(spacing: 0) { ForEach(Array(audioStreams.enumerated()), id: \.element.url) { index, stream in if index > 0 { @@ -614,6 +696,7 @@ extension QualitySelectorView { } } .cardBackground() + #endif } } @@ -647,6 +730,29 @@ extension QualitySelectorView { @ViewBuilder var subtitlesSectionContent: some View { + #if os(tvOS) + VStack(spacing: 8) { + CaptionRowView( + caption: nil, + isSelected: currentCaption == nil, + isPreferred: false, + onTap: { + handleCaptionTap(nil) + } + ) + + ForEach(sortedCaptions) { caption in + CaptionRowView( + caption: caption, + isSelected: caption.id == currentCaption?.id, + isPreferred: isCaptionPreferred(caption), + onTap: { + handleCaptionTap(caption) + } + ) + } + } + #else VStack(spacing: 0) { CaptionRowView( caption: nil, @@ -675,6 +781,7 @@ extension QualitySelectorView { } } .cardBackground() + #endif } private func isCaptionPreferred(_ caption: Caption) -> Bool { @@ -691,3 +798,27 @@ extension QualitySelectorView { dismiss() } } + +#if os(tvOS) +/// Row button style for Settings-style navigation/selection rows on tvOS. +/// Avoids the default focus lift/scale so rows stay aligned; focus state is +/// communicated via a background tint and a thin stroke. +struct TVSettingsRowButtonStyle: ButtonStyle { + @Environment(\.isFocused) private var isFocused + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(.white) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(isFocused ? Color.white.opacity(0.22) : Color.white.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(isFocused ? Color.white.opacity(0.4) : .clear, lineWidth: 2) + ) + .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .animation(.easeInOut(duration: 0.15), value: isFocused) + } +} +#endif diff --git a/Yattee/Views/Player/QualitySelectorView.swift b/Yattee/Views/Player/QualitySelectorView.swift index cee821c2..17d0b40c 100644 --- a/Yattee/Views/Player/QualitySelectorView.swift +++ b/Yattee/Views/Player/QualitySelectorView.swift @@ -191,6 +191,11 @@ struct QualitySelectorView: View { #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) { @@ -201,6 +206,7 @@ struct QualitySelectorView: View { } } } + #endif .navigationDestination(for: QualitySelectorDestination.self) { destination in switch destination { case .video: diff --git a/Yattee/Views/Player/tvOS/TVDetailsPanel.swift b/Yattee/Views/Player/tvOS/TVDetailsPanel.swift index 0bef682d..c984f7c0 100644 --- a/Yattee/Views/Player/tvOS/TVDetailsPanel.swift +++ b/Yattee/Views/Player/tvOS/TVDetailsPanel.swift @@ -12,12 +12,20 @@ import NukeUI /// Details panel that slides up from the bottom showing video information. struct TVDetailsPanel: View { let video: Video? + let initialTab: TVDetailsTab let onDismiss: () -> Void @Environment(\.appEnvironment) private var appEnvironment /// Tab selection for Info / Comments. - @State private var selectedTab: TVDetailsTab = .info + @State private var selectedTab: TVDetailsTab + + init(video: Video?, initialTab: TVDetailsTab = .info, onDismiss: @escaping () -> Void) { + self.video = video + self.initialTab = initialTab + self.onDismiss = onDismiss + _selectedTab = State(initialValue: initialTab) + } /// Focus state for interactive elements. @FocusState private var focusedItem: TVDetailsFocusItem? diff --git a/Yattee/Views/Player/tvOS/TVPlayerControlsView.swift b/Yattee/Views/Player/tvOS/TVPlayerControlsView.swift index 6995f140..c8d26b62 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerControlsView.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerControlsView.swift @@ -15,16 +15,17 @@ struct TVPlayerControlsView: View { let playerService: PlayerService? @FocusState.Binding var focusedControl: TVPlayerFocusTarget? + let onShowSettings: () -> Void let onShowDetails: () -> Void - let onShowQuality: () -> Void + let onShowComments: () -> Void let onShowDebug: () -> Void - let onDismiss: () -> Void + let onClose: () -> Void /// Called when scrubbing state changes - parent should stop auto-hide timer when true var onScrubbingChanged: ((Bool) -> Void)? - /// Whether to show in-app volume controls (only when volume mode is .mpv) - private var showVolumeControls: Bool { - GlobalLayoutSettings.cached.volumeMode == .mpv + /// Whether the Debug button should be visible (user-toggled in Developer settings). + private var showDebugButton: Bool { + appEnvironment?.settingsManager.showTVDebugButton ?? false } @State private var playNextTapCount = 0 @@ -212,47 +213,19 @@ struct TVPlayerControlsView: View { private var actionButtons: some View { HStack(spacing: 40) { - // Quality selector + // Settings (video / audio / subtitles / speed) Button { - onShowQuality() + onShowSettings() } label: { VStack(spacing: 6) { - Image(systemName: "slider.horizontal.3") + Image(systemName: "gearshape") .font(.system(size: 28)) - Text("player.controls.quality") + Text("player.controls.settings") .font(.caption) } } .buttonStyle(TVActionButtonStyle()) - .focused($focusedControl, equals: .qualityButton) - - // Captions - Button { - // TODO: Show captions picker - } label: { - VStack(spacing: 6) { - Image(systemName: "captions.bubble") - .font(.system(size: 28)) - Text(String(localized: "player.controls.subtitles")) - .font(.caption) - } - } - .buttonStyle(TVActionButtonStyle()) - .focused($focusedControl, equals: .captionsButton) - - // Debug overlay - Button { - onShowDebug() - } label: { - VStack(spacing: 6) { - Image(systemName: "ant.circle") - .font(.system(size: 28)) - Text(String(localized: "player.debug.titleShort")) - .font(.caption) - } - } - .buttonStyle(TVActionButtonStyle()) - .focused($focusedControl, equals: .debugButton) + .focused($focusedControl, equals: .settingsButton) // Info / Details Button { @@ -268,43 +241,36 @@ struct TVPlayerControlsView: View { .buttonStyle(TVActionButtonStyle()) .focused($focusedControl, equals: .infoButton) - // Volume controls (only when in-app volume mode) - if showVolumeControls { - // Volume down + // Comments (opens details panel on Comments tab) + if playerState?.currentVideo?.supportsComments == true { Button { - guard let state = playerState else { return } - let newVolume = max(0, state.volume - 0.1) - playerService?.currentBackend?.volume = newVolume - playerService?.state.volume = newVolume - appEnvironment?.settingsManager.playerVolume = newVolume + onShowComments() } label: { VStack(spacing: 6) { - Image(systemName: "speaker.minus") + Image(systemName: "bubble.left.and.bubble.right") .font(.system(size: 28)) - Text(String(localized: "player.tvos.volumeDown")) + Text("player.controls.comments") .font(.caption) } } .buttonStyle(TVActionButtonStyle()) - .focused($focusedControl, equals: .volumeDown) + .focused($focusedControl, equals: .commentsButton) + } - // Volume up + // Debug overlay (only when enabled in Developer settings) + if showDebugButton { Button { - guard let state = playerState else { return } - let newVolume = min(1.0, state.volume + 0.1) - playerService?.currentBackend?.volume = newVolume - playerService?.state.volume = newVolume - appEnvironment?.settingsManager.playerVolume = newVolume + onShowDebug() } label: { VStack(spacing: 6) { - Image(systemName: "speaker.plus") + Image(systemName: "ant.circle") .font(.system(size: 28)) - Text(String(localized: "player.tvos.volumeUp")) + Text(String(localized: "player.debug.titleShort")) .font(.caption) } } .buttonStyle(TVActionButtonStyle()) - .focused($focusedControl, equals: .volumeUp) + .focused($focusedControl, equals: .debugButton) } // Play next button (when queue has items) @@ -325,6 +291,20 @@ struct TVPlayerControlsView: View { .focused($focusedControl, equals: .playNext) } + // Close (stops playback and dismisses) + Button { + onClose() + } label: { + VStack(spacing: 6) { + Image(systemName: "xmark.circle") + .font(.system(size: 28)) + Text("player.controls.close") + .font(.caption) + } + } + .buttonStyle(TVActionButtonStyle()) + .focused($focusedControl, equals: .closeButton) + Spacer() // Queue indicator (if videos in queue) @@ -365,7 +345,9 @@ struct TVActionButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundStyle(.white) - .frame(width: 100, height: 80) + .lineLimit(1) + .minimumScaleFactor(0.8) + .frame(width: 140, height: 80) .background( RoundedRectangle(cornerRadius: 12) .fill(isFocused ? .white.opacity(0.3) : .white.opacity(0.1)) diff --git a/Yattee/Views/Player/tvOS/TVPlayerView.swift b/Yattee/Views/Player/tvOS/TVPlayerView.swift index 5fbbaaa5..d9cfbdcb 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerView.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerView.swift @@ -15,13 +15,12 @@ enum TVPlayerFocusTarget: Hashable { case playPause case skipForward case progressBar - case qualityButton - case captionsButton - case debugButton + case settingsButton case infoButton - case volumeDown - case volumeUp + case commentsButton + case debugButton case playNext + case closeButton } /// Main tvOS fullscreen player view. @@ -40,6 +39,9 @@ struct TVPlayerView: View { /// Whether the details panel is shown. @State private var isDetailsPanelVisible = false + /// Initial tab for the details panel when opened. + @State private var detailsPanelInitialTab: TVDetailsTab = .info + /// Whether user is scrubbing the progress bar. @State private var isScrubbing = false @@ -83,56 +85,77 @@ struct TVPlayerView: View { mpvPlayerContent .ignoresSafeArea() .playerToastOverlay() - // Quality selector sheet - .sheet(isPresented: $showingQualitySheet) { - if let playerService { - let dashEnabled = appEnvironment?.settingsManager.dashEnabled ?? false - let supportedFormats = playerService.currentBackendType.supportedFormats - 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) - } - ) - } + // Quality / Settings selector (fullscreen cover gives tvOS enough room) + .fullScreenCover(isPresented: $showingQualitySheet) { + qualitySheetContent } } + // MARK: - Quality Sheet Content + + @ViewBuilder + private var qualitySheetContent: some View { + if let playerService { + let dashEnabled = appEnvironment?.settingsManager.dashEnabled ?? false + let supportedFormats = playerService.currentBackendType.supportedFormats + + ZStack { + // Dimmed backdrop over the video + Color.black.opacity(0.7) + .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) + } + ) + .frame(maxWidth: 900, maxHeight: 700) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(.ultraThinMaterial) + ) + .padding(.horizontal, 200) + .padding(.vertical, 80) + } + } + } + // MARK: - MPV Content /// Custom MPV player view with custom controls. @@ -151,10 +174,11 @@ struct TVPlayerView: View { playerState: playerState, playerService: playerService, focusedControl: $focusedControl, - onShowDetails: { showDetailsPanel() }, - onShowQuality: { showQualitySheet() }, + onShowSettings: { showQualitySheet() }, + onShowDetails: { showDetailsPanel(tab: .info) }, + onShowComments: { showDetailsPanel(tab: .comments) }, onShowDebug: { showDebugOverlay() }, - onDismiss: { dismissPlayer() }, + onClose: { closeVideo() }, onScrubbingChanged: { scrubbing in isScrubbing = scrubbing if scrubbing { @@ -171,6 +195,7 @@ struct TVPlayerView: View { if isDetailsPanelVisible { TVDetailsPanel( video: playerState?.currentVideo, + initialTab: detailsPanelInitialTab, onDismiss: { hideDetailsPanel() } ) .transition(.move(edge: .bottom).combined(with: .opacity)) @@ -336,8 +361,9 @@ struct TVPlayerView: View { // MARK: - Details Panel - private func showDetailsPanel() { + private func showDetailsPanel(tab: TVDetailsTab = .info) { stopControlsTimer() + detailsPanelInitialTab = tab withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isDetailsPanelVisible = true controlsVisible = false @@ -466,6 +492,14 @@ struct TVPlayerView: View { } } + private func closeVideo() { + playerState?.isClosingVideo = true + appEnvironment?.queueManager.clearQueue() + playerService?.stop() + appEnvironment?.navigationCoordinator.isPlayerExpanded = false + dismiss() + } + private func dismissPlayer() { // Collapse the player but keep it alive so audio continues in the background // and the "Now Playing" sidebar entry can restore the session. Matches the diff --git a/Yattee/Views/Settings/DeveloperSettingsView.swift b/Yattee/Views/Settings/DeveloperSettingsView.swift index c302e54b..958e185f 100644 --- a/Yattee/Views/Settings/DeveloperSettingsView.swift +++ b/Yattee/Views/Settings/DeveloperSettingsView.swift @@ -156,6 +156,15 @@ struct DeveloperSettingsView: View { Label(String(localized: "settings.advanced.debug.zoomTransitions"), systemImage: "arrow.up.left.and.arrow.down.right") } #endif + + #if os(tvOS) + Toggle(isOn: Binding( + get: { settingsManager.showTVDebugButton }, + set: { settingsManager.showTVDebugButton = $0 } + )) { + Label(String(localized: "settings.advanced.debug.showTVDebugButton"), systemImage: "ant.circle") + } + #endif } header: { Text(String(localized: "settings.advanced.debug.sectionTitle")) }