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

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