Rework tvOS player controls and settings sheet

Replace the tvOS bottom action bar with Settings / Info / Comments /
Next / Close. Settings reuses QualitySelectorView (video, audio,
subtitles, speed); Comments opens TVDetailsPanel directly on the
comments tab; Close stops playback and dismisses.

Debug button is hidden by default and can be re-enabled via a new
tvOS-only Advanced Settings > Developer toggle.

Present the settings sheet as a fullScreenCover with a centered
material card, fix the "Normal" hyphenation, and restyle row selection
throughout the quality selector on tvOS: per-row rounded backgrounds
with focus tint + stroke, vertical spacing instead of dividers, and a
focusable speed-rate menu.
This commit is contained in:
Arkadiusz Fal
2026-04-14 17:34:20 +02:00
parent 4f9285686a
commit c7942ef555
11 changed files with 443 additions and 169 deletions

View File

@@ -93,6 +93,7 @@ enum SettingsKey: String, CaseIterable {
// Advanced
case showAdvancedStreamDetails
case showPlayerAreaDebug
case showTVDebugButton
case verboseMPVLogging
case verboseRemoteControlLogging
case mpvBufferSeconds

View File

@@ -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).

View File

@@ -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

View File

@@ -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" : {

View File

@@ -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

View File

@@ -77,65 +77,91 @@ extension QualitySelectorView {
@ViewBuilder
private var mediaSelectionRows: some View {
#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
)
}
if availableTabs.contains(.subtitles) {
mediaSelectionRow(
destination: .subtitles,
label: String(localized: "stream.subtitles"),
systemImage: "captions.bubble",
value: currentSubtitlesDisplayValue
)
}
}
#else
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())
}
.buttonStyle(.plain)
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)
mediaSelectionRow(
destination: .audio,
label: String(localized: "stream.audio"),
systemImage: "speaker.wave.2",
value: currentAudioDisplayValue
)
}
.padding(.vertical, 12)
.padding(.horizontal, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
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

View File

@@ -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:

View File

@@ -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?

View File

@@ -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))

View File

@@ -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,11 +85,25 @@ struct TVPlayerView: View {
mpvPlayerContent
.ignoresSafeArea()
.playerToastOverlay()
// Quality selector sheet
.sheet(isPresented: $showingQualitySheet) {
// 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)
@@ -129,6 +145,13 @@ struct TVPlayerView: View {
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)
}
}
}
@@ -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

View File

@@ -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"))
}