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 // Advanced
case showAdvancedStreamDetails case showAdvancedStreamDetails
case showPlayerAreaDebug case showPlayerAreaDebug
case showTVDebugButton
case verboseMPVLogging case verboseMPVLogging
case verboseRemoteControlLogging case verboseRemoteControlLogging
case mpvBufferSeconds 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. /// Whether verbose MPV rendering logging is enabled.
/// When enabled, logs detailed OpenGL context, framebuffer, and display link state /// When enabled, logs detailed OpenGL context, framebuffer, and display link state
/// to help diagnose rendering issues. Default is false (disabled). /// to help diagnose rendering issues. Default is false (disabled).

View File

@@ -140,6 +140,7 @@ final class SettingsManager {
// Advanced settings // Advanced settings
var _showAdvancedStreamDetails: Bool? var _showAdvancedStreamDetails: Bool?
var _showPlayerAreaDebug: Bool? var _showPlayerAreaDebug: Bool?
var _showTVDebugButton: Bool?
var _verboseMPVLogging: Bool? var _verboseMPVLogging: Bool?
var _verboseRemoteControlLogging: Bool? var _verboseRemoteControlLogging: Bool?
var _mpvBufferSeconds: Double? var _mpvBufferSeconds: Double?
@@ -465,6 +466,7 @@ final class SettingsManager {
_sidebarPlaylistsLimitEnabled = nil _sidebarPlaylistsLimitEnabled = nil
_showAdvancedStreamDetails = nil _showAdvancedStreamDetails = nil
_showPlayerAreaDebug = nil _showPlayerAreaDebug = nil
_showTVDebugButton = nil
_verboseMPVLogging = nil _verboseMPVLogging = nil
_verboseRemoteControlLogging = nil _verboseRemoteControlLogging = nil
_mpvBufferSeconds = 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" : { "player.controls.info" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -6534,6 +6554,7 @@
} }
}, },
"player.controls.quality" : { "player.controls.quality" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -6543,7 +6564,18 @@
} }
} }
}, },
"player.controls.settings" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Settings"
}
}
}
},
"player.controls.subtitles" : { "player.controls.subtitles" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -7052,6 +7084,7 @@
} }
}, },
"player.tvos.volumeDown" : { "player.tvos.volumeDown" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -7062,6 +7095,7 @@
} }
}, },
"player.tvos.volumeUp" : { "player.tvos.volumeUp" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "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" : { "settings.advanced.debug.verboseMPV" : {
"comment" : "Toggle label", "comment" : "Toggle label",
"localizations" : { "localizations" : {

View File

@@ -7,6 +7,13 @@
import SwiftUI 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 // MARK: - Adaptive Stream Row
/// Row view for HLS/DASH adaptive streams. /// Row view for HLS/DASH adaptive streams.
@@ -61,9 +68,18 @@ struct AdaptiveStreamRowView: View {
.foregroundStyle(.tint) .foregroundStyle(.tint)
} }
} }
#if os(tvOS)
.padding(.vertical, tvRowVerticalPadding)
.padding(.horizontal, tvRowHorizontalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
#if os(tvOS)
.buttonStyle(TVSettingsRowButtonStyle())
#else
.buttonStyle(.plain) .buttonStyle(.plain)
#endif
} }
} }
@@ -97,9 +113,18 @@ struct VideoStreamRowView: View {
} }
} }
.frame(minHeight: showAdvancedDetails ? nil : 36) .frame(minHeight: showAdvancedDetails ? nil : 36)
#if os(tvOS)
.padding(.vertical, tvRowVerticalPadding)
.padding(.horizontal, tvRowHorizontalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
#if os(tvOS)
.buttonStyle(TVSettingsRowButtonStyle())
#else
.buttonStyle(.plain) .buttonStyle(.plain)
#endif
} }
@ViewBuilder @ViewBuilder
@@ -251,9 +276,18 @@ struct AudioStreamRowView: View {
} }
} }
.frame(minHeight: showAdvancedDetails ? nil : 36) .frame(minHeight: showAdvancedDetails ? nil : 36)
#if os(tvOS)
.padding(.vertical, tvRowVerticalPadding)
.padding(.horizontal, tvRowHorizontalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
#if os(tvOS)
.buttonStyle(TVSettingsRowButtonStyle())
#else
.buttonStyle(.plain) .buttonStyle(.plain)
#endif
} }
@ViewBuilder @ViewBuilder
@@ -341,9 +375,18 @@ struct CaptionRowView: View {
} }
} }
.frame(minHeight: 36) .frame(minHeight: 36)
#if os(tvOS)
.padding(.vertical, tvRowVerticalPadding)
.padding(.horizontal, tvRowHorizontalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
#if os(tvOS)
.buttonStyle(TVSettingsRowButtonStyle())
#else
.buttonStyle(.plain) .buttonStyle(.plain)
#endif
} }
@ViewBuilder @ViewBuilder

View File

@@ -77,65 +77,91 @@ extension QualitySelectorView {
@ViewBuilder @ViewBuilder
private var mediaSelectionRows: some View { private var mediaSelectionRows: some View {
VStack(spacing: 0) { #if os(tvOS)
NavigationLink(value: QualitySelectorDestination.video) { // tvOS: each row is its own rounded card with vertical spacing so the
HStack { // system focus hover effect has clean bounds (no clipped dividers).
Label(String(localized: "player.quality.video"), systemImage: "film") VStack(spacing: 8) {
.font(.headline) mediaSelectionRow(
Spacer() destination: .video,
Text(currentVideoDisplayValue) label: String(localized: "player.quality.video"),
.foregroundStyle(.secondary) systemImage: "film",
Image(systemName: "chevron.right") value: currentVideoDisplayValue
.font(.caption.weight(.semibold)) )
.foregroundStyle(.tertiary) if availableTabs.contains(.audio) {
} mediaSelectionRow(
.padding(.vertical, 12) destination: .audio,
.padding(.horizontal, 12) label: String(localized: "stream.audio"),
.contentShape(Rectangle()) 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) { if availableTabs.contains(.audio) {
Divider() Divider()
NavigationLink(value: QualitySelectorDestination.audio) { mediaSelectionRow(
HStack { destination: .audio,
Label(String(localized: "stream.audio"), systemImage: "speaker.wave.2") label: String(localized: "stream.audio"),
.font(.headline) systemImage: "speaker.wave.2",
Spacer() value: currentAudioDisplayValue
Text(currentAudioDisplayValue) )
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.vertical, 12)
.padding(.horizontal, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
} }
if availableTabs.contains(.subtitles) { if availableTabs.contains(.subtitles) {
Divider() Divider()
NavigationLink(value: QualitySelectorDestination.subtitles) { mediaSelectionRow(
HStack { destination: .subtitles,
Label(String(localized: "stream.subtitles"), systemImage: "captions.bubble") label: String(localized: "stream.subtitles"),
.font(.headline) systemImage: "captions.bubble",
Spacer() value: currentSubtitlesDisplayValue
Text(currentSubtitlesDisplayValue) )
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.vertical, 12)
.padding(.horizontal, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
} }
} }
.cardBackground() .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 // MARK: - Display Value Computed Properties
@@ -261,16 +287,19 @@ extension QualitySelectorView {
@ViewBuilder @ViewBuilder
var generalSectionContent: some View { 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) { VStack(spacing: 0) {
playbackSpeedRow playbackSpeedRow
Divider() Divider()
#if !os(tvOS)
lockControlsRow lockControlsRow
#endif
} }
.cardBackground() .cardBackground()
#endif
} }
@ViewBuilder @ViewBuilder
@@ -310,9 +339,15 @@ extension QualitySelectorView {
} label: { } label: {
Text(currentRate.displayText) Text(currentRate.displayText)
.font(.body.weight(.medium)) .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) .menuStyle(.borderlessButton)
#endif
Button { Button {
if let newRate = nextRate() { if let newRate = nextRate() {
@@ -328,8 +363,18 @@ extension QualitySelectorView {
.disabled(nextRate() == nil) .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(.vertical, 12)
.padding(.horizontal, 12) .padding(.horizontal, 12)
#endif
} }
#if !os(tvOS) #if !os(tvOS)
@@ -455,6 +500,19 @@ extension QualitySelectorView {
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary) .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) { VStack(spacing: 0) {
ForEach(Array(adaptiveStreams.enumerated()), id: \.element.url) { index, stream in ForEach(Array(adaptiveStreams.enumerated()), id: \.element.url) { index, stream in
if index > 0 { if index > 0 {
@@ -472,6 +530,7 @@ extension QualitySelectorView {
} }
} }
.cardBackground() .cardBackground()
#endif
} }
} }
@@ -496,6 +555,13 @@ extension QualitySelectorView {
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
#if os(tvOS)
VStack(spacing: 8) {
ForEach(recommendedVideoStreams, id: \.url) { stream in
videoStreamRow(stream)
}
}
#else
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(Array(recommendedVideoStreams.enumerated()), id: \.element.url) { index, stream in ForEach(Array(recommendedVideoStreams.enumerated()), id: \.element.url) { index, stream in
if index > 0 { if index > 0 {
@@ -507,6 +573,7 @@ extension QualitySelectorView {
} }
} }
.cardBackground() .cardBackground()
#endif
} }
} }
@@ -517,6 +584,13 @@ extension QualitySelectorView {
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
#if os(tvOS)
VStack(spacing: 8) {
ForEach(otherVideoStreams, id: \.url) { stream in
videoStreamRow(stream)
}
}
#else
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(Array(otherVideoStreams.enumerated()), id: \.element.url) { index, stream in ForEach(Array(otherVideoStreams.enumerated()), id: \.element.url) { index, stream in
if index > 0 { if index > 0 {
@@ -528,6 +602,7 @@ extension QualitySelectorView {
} }
} }
.cardBackground() .cardBackground()
#endif
} }
} }
} }
@@ -603,6 +678,13 @@ extension QualitySelectorView {
} }
.cardBackground() .cardBackground()
} else { } else {
#if os(tvOS)
VStack(spacing: 8) {
ForEach(audioStreams, id: \.url) { stream in
audioStreamRow(stream)
}
}
#else
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(Array(audioStreams.enumerated()), id: \.element.url) { index, stream in ForEach(Array(audioStreams.enumerated()), id: \.element.url) { index, stream in
if index > 0 { if index > 0 {
@@ -614,6 +696,7 @@ extension QualitySelectorView {
} }
} }
.cardBackground() .cardBackground()
#endif
} }
} }
@@ -647,6 +730,29 @@ extension QualitySelectorView {
@ViewBuilder @ViewBuilder
var subtitlesSectionContent: some View { 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) { VStack(spacing: 0) {
CaptionRowView( CaptionRowView(
caption: nil, caption: nil,
@@ -675,6 +781,7 @@ extension QualitySelectorView {
} }
} }
.cardBackground() .cardBackground()
#endif
} }
private func isCaptionPreferred(_ caption: Caption) -> Bool { private func isCaptionPreferred(_ caption: Caption) -> Bool {
@@ -691,3 +798,27 @@ extension QualitySelectorView {
dismiss() 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) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #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 { .toolbar {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button(role: .cancel) { Button(role: .cancel) {
@@ -201,6 +206,7 @@ struct QualitySelectorView: View {
} }
} }
} }
#endif
.navigationDestination(for: QualitySelectorDestination.self) { destination in .navigationDestination(for: QualitySelectorDestination.self) { destination in
switch destination { switch destination {
case .video: case .video:

View File

@@ -12,12 +12,20 @@ import NukeUI
/// Details panel that slides up from the bottom showing video information. /// Details panel that slides up from the bottom showing video information.
struct TVDetailsPanel: View { struct TVDetailsPanel: View {
let video: Video? let video: Video?
let initialTab: TVDetailsTab
let onDismiss: () -> Void let onDismiss: () -> Void
@Environment(\.appEnvironment) private var appEnvironment @Environment(\.appEnvironment) private var appEnvironment
/// Tab selection for Info / Comments. /// 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. /// Focus state for interactive elements.
@FocusState private var focusedItem: TVDetailsFocusItem? @FocusState private var focusedItem: TVDetailsFocusItem?

View File

@@ -15,16 +15,17 @@ struct TVPlayerControlsView: View {
let playerService: PlayerService? let playerService: PlayerService?
@FocusState.Binding var focusedControl: TVPlayerFocusTarget? @FocusState.Binding var focusedControl: TVPlayerFocusTarget?
let onShowSettings: () -> Void
let onShowDetails: () -> Void let onShowDetails: () -> Void
let onShowQuality: () -> Void let onShowComments: () -> Void
let onShowDebug: () -> Void let onShowDebug: () -> Void
let onDismiss: () -> Void let onClose: () -> Void
/// Called when scrubbing state changes - parent should stop auto-hide timer when true /// Called when scrubbing state changes - parent should stop auto-hide timer when true
var onScrubbingChanged: ((Bool) -> Void)? var onScrubbingChanged: ((Bool) -> Void)?
/// Whether to show in-app volume controls (only when volume mode is .mpv) /// Whether the Debug button should be visible (user-toggled in Developer settings).
private var showVolumeControls: Bool { private var showDebugButton: Bool {
GlobalLayoutSettings.cached.volumeMode == .mpv appEnvironment?.settingsManager.showTVDebugButton ?? false
} }
@State private var playNextTapCount = 0 @State private var playNextTapCount = 0
@@ -212,47 +213,19 @@ struct TVPlayerControlsView: View {
private var actionButtons: some View { private var actionButtons: some View {
HStack(spacing: 40) { HStack(spacing: 40) {
// Quality selector // Settings (video / audio / subtitles / speed)
Button { Button {
onShowQuality() onShowSettings()
} label: { } label: {
VStack(spacing: 6) { VStack(spacing: 6) {
Image(systemName: "slider.horizontal.3") Image(systemName: "gearshape")
.font(.system(size: 28)) .font(.system(size: 28))
Text("player.controls.quality") Text("player.controls.settings")
.font(.caption) .font(.caption)
} }
} }
.buttonStyle(TVActionButtonStyle()) .buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .qualityButton) .focused($focusedControl, equals: .settingsButton)
// 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)
// Info / Details // Info / Details
Button { Button {
@@ -268,43 +241,36 @@ struct TVPlayerControlsView: View {
.buttonStyle(TVActionButtonStyle()) .buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .infoButton) .focused($focusedControl, equals: .infoButton)
// Volume controls (only when in-app volume mode) // Comments (opens details panel on Comments tab)
if showVolumeControls { if playerState?.currentVideo?.supportsComments == true {
// Volume down
Button { Button {
guard let state = playerState else { return } onShowComments()
let newVolume = max(0, state.volume - 0.1)
playerService?.currentBackend?.volume = newVolume
playerService?.state.volume = newVolume
appEnvironment?.settingsManager.playerVolume = newVolume
} label: { } label: {
VStack(spacing: 6) { VStack(spacing: 6) {
Image(systemName: "speaker.minus") Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 28)) .font(.system(size: 28))
Text(String(localized: "player.tvos.volumeDown")) Text("player.controls.comments")
.font(.caption) .font(.caption)
} }
} }
.buttonStyle(TVActionButtonStyle()) .buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .volumeDown) .focused($focusedControl, equals: .commentsButton)
}
// Volume up // Debug overlay (only when enabled in Developer settings)
if showDebugButton {
Button { Button {
guard let state = playerState else { return } onShowDebug()
let newVolume = min(1.0, state.volume + 0.1)
playerService?.currentBackend?.volume = newVolume
playerService?.state.volume = newVolume
appEnvironment?.settingsManager.playerVolume = newVolume
} label: { } label: {
VStack(spacing: 6) { VStack(spacing: 6) {
Image(systemName: "speaker.plus") Image(systemName: "ant.circle")
.font(.system(size: 28)) .font(.system(size: 28))
Text(String(localized: "player.tvos.volumeUp")) Text(String(localized: "player.debug.titleShort"))
.font(.caption) .font(.caption)
} }
} }
.buttonStyle(TVActionButtonStyle()) .buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .volumeUp) .focused($focusedControl, equals: .debugButton)
} }
// Play next button (when queue has items) // Play next button (when queue has items)
@@ -325,6 +291,20 @@ struct TVPlayerControlsView: View {
.focused($focusedControl, equals: .playNext) .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() Spacer()
// Queue indicator (if videos in queue) // Queue indicator (if videos in queue)
@@ -365,7 +345,9 @@ struct TVActionButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label configuration.label
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(width: 100, height: 80) .lineLimit(1)
.minimumScaleFactor(0.8)
.frame(width: 140, height: 80)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(isFocused ? .white.opacity(0.3) : .white.opacity(0.1)) .fill(isFocused ? .white.opacity(0.3) : .white.opacity(0.1))

View File

@@ -15,13 +15,12 @@ enum TVPlayerFocusTarget: Hashable {
case playPause case playPause
case skipForward case skipForward
case progressBar case progressBar
case qualityButton case settingsButton
case captionsButton
case debugButton
case infoButton case infoButton
case volumeDown case commentsButton
case volumeUp case debugButton
case playNext case playNext
case closeButton
} }
/// Main tvOS fullscreen player view. /// Main tvOS fullscreen player view.
@@ -40,6 +39,9 @@ struct TVPlayerView: View {
/// Whether the details panel is shown. /// Whether the details panel is shown.
@State private var isDetailsPanelVisible = false @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. /// Whether user is scrubbing the progress bar.
@State private var isScrubbing = false @State private var isScrubbing = false
@@ -83,56 +85,77 @@ struct TVPlayerView: View {
mpvPlayerContent mpvPlayerContent
.ignoresSafeArea() .ignoresSafeArea()
.playerToastOverlay() .playerToastOverlay()
// Quality selector sheet // Quality / Settings selector (fullscreen cover gives tvOS enough room)
.sheet(isPresented: $showingQualitySheet) { .fullScreenCover(isPresented: $showingQualitySheet) {
if let playerService { qualitySheetContent
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)
}
)
}
} }
} }
// 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 // MARK: - MPV Content
/// Custom MPV player view with custom controls. /// Custom MPV player view with custom controls.
@@ -151,10 +174,11 @@ struct TVPlayerView: View {
playerState: playerState, playerState: playerState,
playerService: playerService, playerService: playerService,
focusedControl: $focusedControl, focusedControl: $focusedControl,
onShowDetails: { showDetailsPanel() }, onShowSettings: { showQualitySheet() },
onShowQuality: { showQualitySheet() }, onShowDetails: { showDetailsPanel(tab: .info) },
onShowComments: { showDetailsPanel(tab: .comments) },
onShowDebug: { showDebugOverlay() }, onShowDebug: { showDebugOverlay() },
onDismiss: { dismissPlayer() }, onClose: { closeVideo() },
onScrubbingChanged: { scrubbing in onScrubbingChanged: { scrubbing in
isScrubbing = scrubbing isScrubbing = scrubbing
if scrubbing { if scrubbing {
@@ -171,6 +195,7 @@ struct TVPlayerView: View {
if isDetailsPanelVisible { if isDetailsPanelVisible {
TVDetailsPanel( TVDetailsPanel(
video: playerState?.currentVideo, video: playerState?.currentVideo,
initialTab: detailsPanelInitialTab,
onDismiss: { hideDetailsPanel() } onDismiss: { hideDetailsPanel() }
) )
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
@@ -336,8 +361,9 @@ struct TVPlayerView: View {
// MARK: - Details Panel // MARK: - Details Panel
private func showDetailsPanel() { private func showDetailsPanel(tab: TVDetailsTab = .info) {
stopControlsTimer() stopControlsTimer()
detailsPanelInitialTab = tab
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
isDetailsPanelVisible = true isDetailsPanelVisible = true
controlsVisible = false 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() { private func dismissPlayer() {
// Collapse the player but keep it alive so audio continues in the background // 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 // 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") Label(String(localized: "settings.advanced.debug.zoomTransitions"), systemImage: "arrow.up.left.and.arrow.down.right")
} }
#endif #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: { } header: {
Text(String(localized: "settings.advanced.debug.sectionTitle")) Text(String(localized: "settings.advanced.debug.sectionTitle"))
} }