Redesign tvOS player controls with centered transport cluster

- Move Close to a top-right circular icon; bottom row reorganizes into
  left (Settings/Info/Comments), center transport (Previous/PlayPause/Next),
  and right (Queue) clusters with equal side frames so transport stays
  geometrically centered.
- Introduce a circular icon-only `TVTransportButtonStyle` (primary variant
  for Play/Pause) mirroring the new Close button look.
- Always render Previous/Next so Play/Pause position is fixed; dim and
  disable when unavailable.
- Share `isTransportDisabled` on `PlayerState` and reuse it on iOS and
  tvOS; apply it (plus symbol replace transition) to the tvOS Play/Pause
  button.
This commit is contained in:
Arkadiusz Fal
2026-04-17 21:44:41 +02:00
parent c0184712a9
commit 096df34f64
5 changed files with 216 additions and 106 deletions

View File

@@ -6671,6 +6671,26 @@
} }
} }
}, },
"player.controls.pause" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pause"
}
}
}
},
"player.controls.play" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Play"
}
}
}
},
"player.controls.quality" : { "player.controls.quality" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {

View File

@@ -422,6 +422,16 @@ final class PlayerState {
videoAspectRatio ?? (16.0 / 9.0) videoAspectRatio ?? (16.0 / 9.0)
} }
/// Whether transport controls (play/pause, seek) should be blocked because
/// the player isn't ready yet loading, buffering, or waiting for the
/// first frame / initial buffer. Shared by iOS and tvOS control overlays.
var isTransportDisabled: Bool {
playbackState == .loading
|| playbackState == .buffering
|| !isFirstFrameReady
|| !isBufferReady
}
// MARK: - Methods // MARK: - Methods
/// Updates the current video and stream. /// Updates the current video and stream.

View File

@@ -807,10 +807,7 @@ struct PlayerControlsView: View {
/// Whether transport controls should be disabled (during loading/buffering or buffer not ready) /// Whether transport controls should be disabled (during loading/buffering or buffer not ready)
private var isTransportDisabled: Bool { private var isTransportDisabled: Bool {
playerState.playbackState == .loading || playerState.isTransportDisabled
playerState.playbackState == .buffering ||
!playerState.isFirstFrameReady ||
!playerState.isBufferReady
} }
private var playPauseIcon: String { private var playPauseIcon: String {

View File

@@ -39,6 +39,19 @@ struct TVPlayerControlsView: View {
@State private var playNextTapCount = 0 @State private var playNextTapCount = 0
@State private var playPreviousTapCount = 0 @State private var playPreviousTapCount = 0
@State private var playPauseTapCount = 0
private var isPlaying: Bool {
playerState?.playbackState == .playing
}
private var isTransportDisabled: Bool {
playerState?.isTransportDisabled ?? true
}
private var playPauseIcon: String {
isPlaying ? "pause.fill" : "play.fill"
}
var body: some View { var body: some View {
ZStack { ZStack {
@@ -151,7 +164,21 @@ struct TVPlayerControlsView: View {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
.scaleEffect(1.5) .scaleEffect(1.5)
.padding(.trailing, 8)
} }
// Close button stops playback and dismisses.
// Menu button only hides the player (keeps background playback),
// so an explicit Close is kept here, icon-only in the top bar.
Button {
onClose()
} label: {
Image(systemName: "xmark")
.font(.system(size: 26, weight: .semibold))
}
.buttonStyle(TVCloseButtonStyle())
.focused($focusedControl, equals: .closeButton)
.accessibilityLabel(Text("player.controls.close"))
} }
} }
@@ -162,143 +189,156 @@ struct TVPlayerControlsView: View {
// MARK: - Action Buttons // MARK: - Action Buttons
private var actionButtons: some View { private var actionButtons: some View {
HStack(spacing: 40) { HStack(spacing: 24) {
// Settings (video / audio / subtitles / speed) // MARK: Left cluster info / meta actions
Button { HStack(spacing: 24) {
onShowSettings()
} label: {
VStack(spacing: 6) {
Image(systemName: "gearshape")
.font(.system(size: 28))
Text("player.controls.settings")
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .settingsButton)
// Info / Details
Button {
onShowDetails()
} label: {
VStack(spacing: 6) {
Image(systemName: "info.circle")
.font(.system(size: 28))
Text("player.controls.info")
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .infoButton)
// Comments (opens details panel on Comments tab)
if playerState?.currentVideo?.supportsComments == true {
Button { Button {
onShowComments() onShowSettings()
} label: { } label: {
VStack(spacing: 6) { TVActionButtonLabel(
Image(systemName: "bubble.left.and.bubble.right") systemImage: "gearshape",
.font(.system(size: 28)) title: String(localized: "player.controls.settings")
Text("player.controls.comments") )
.font(.caption)
}
} }
.buttonStyle(TVActionButtonStyle()) .buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .commentsButton) .focused($focusedControl, equals: .settingsButton)
}
// Debug overlay (only when enabled in Developer settings)
if showDebugButton {
Button { Button {
onShowDebug() onShowDetails()
} label: { } label: {
VStack(spacing: 6) { TVActionButtonLabel(
Image(systemName: "ant.circle") systemImage: "info.circle",
.font(.system(size: 28)) title: String(localized: "player.controls.info")
Text(String(localized: "player.debug.titleShort")) )
.font(.caption)
}
} }
.buttonStyle(TVActionButtonStyle()) .buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .debugButton) .focused($focusedControl, equals: .infoButton)
}
// Play previous button (shown whenever a queue is present; disabled when no history) if playerState?.currentVideo?.supportsComments == true {
if let state = playerState, state.hasNext || state.hasPrevious { Button {
onShowComments()
} label: {
TVActionButtonLabel(
systemImage: "bubble.left.and.bubble.right",
title: String(localized: "player.controls.comments")
)
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .commentsButton)
}
if showDebugButton {
Button {
onShowDebug()
} label: {
TVActionButtonLabel(
systemImage: "ant.circle",
title: String(localized: "player.debug.titleShort")
)
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .debugButton)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
// MARK: Center cluster transport (circular, icon-only)
HStack(spacing: 20) {
// Previous is always rendered so Play/Next stay in a fixed
// position; disabled + dimmed when unavailable.
let hasPrevious = playerState?.hasPrevious == true
Button { Button {
playPreviousTapCount += 1 playPreviousTapCount += 1
Task { await playerService?.playPrevious() } Task { await playerService?.playPrevious() }
} label: { } label: {
VStack(spacing: 6) { Image(systemName: "backward.fill")
Image(systemName: "backward.fill") .font(.system(size: 26, weight: .semibold))
.font(.system(size: 28)) .symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playPreviousTapCount)
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playPreviousTapCount)
Text(String(localized: "player.previous"))
.font(.caption)
}
} }
.buttonStyle(TVActionButtonStyle()) .buttonStyle(TVTransportButtonStyle())
.focused($focusedControl, equals: .playPrevious) .focused($focusedControl, equals: .playPrevious)
.disabled(!state.hasPrevious) .disabled(!hasPrevious)
.opacity(state.hasPrevious ? 1.0 : 0.4) .opacity(hasPrevious ? 1.0 : 0.3)
} .accessibilityLabel(Text("player.previous"))
// Play next button (when queue has items) Button {
if let state = playerState, state.hasNext { playPauseTapCount += 1
playerService?.togglePlayPause()
} label: {
Image(systemName: playPauseIcon)
.font(.system(size: 32, weight: .semibold))
.contentTransition(.symbolEffect(.replace, options: .speed(2)))
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playPauseTapCount)
}
.buttonStyle(TVTransportButtonStyle(isPrimary: true))
.focused($focusedControl, equals: .playPauseButton)
.disabled(isTransportDisabled)
.opacity(isTransportDisabled ? 0.3 : 1.0)
.accessibilityLabel(Text(isPlaying ? "player.controls.pause" : "player.controls.play"))
let hasNext = playerState?.hasNext == true
Button { Button {
playNextTapCount += 1 playNextTapCount += 1
Task { await playerService?.playNext() } Task { await playerService?.playNext() }
} label: { } label: {
VStack(spacing: 6) { Image(systemName: "forward.fill")
Image(systemName: "forward.fill") .font(.system(size: 26, weight: .semibold))
.font(.system(size: 28)) .symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playNextTapCount)
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playNextTapCount)
Text(String(localized: "player.next"))
.font(.caption)
}
} }
.buttonStyle(TVActionButtonStyle()) .buttonStyle(TVTransportButtonStyle())
.focused($focusedControl, equals: .playNext) .focused($focusedControl, equals: .playNext)
.disabled(!hasNext)
.opacity(hasNext ? 1.0 : 0.3)
.accessibilityLabel(Text("player.next"))
} }
Spacer() // MARK: Right cluster queue
HStack(spacing: 24) {
Spacer(minLength: 0)
// Queue button (if videos in queue) if let state = playerState, state.hasNext {
if let state = playerState, state.hasNext { Button {
Button { onShowQueue()
onShowQueue() } label: {
} label: { TVActionButtonLabel(
VStack(spacing: 6) { systemImage: "list.bullet",
Image(systemName: "list.bullet") title: String(localized: "queue.section.count \(state.queue.count)")
.font(.system(size: 28)) )
Text(String(localized: "queue.section.count \(state.queue.count)"))
.font(.caption)
} }
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .queueButton)
} }
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .queueButton)
} }
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
// Close (stops playback and dismisses) // MARK: - Button Label
Button {
onClose() /// Shared label for action buttons: icon always visible, title only on focus.
} label: { private struct TVActionButtonLabel: View {
VStack(spacing: 6) { let systemImage: String
Image(systemName: "xmark.circle") let title: String
.font(.system(size: 28)) var symbolEffectTrigger: Int = 0
Text("player.controls.close")
.font(.caption) var body: some View {
} VStack(spacing: 6) {
} Image(systemName: systemImage)
.buttonStyle(TVActionButtonStyle()) .font(.system(size: 28))
.focused($focusedControl, equals: .closeButton) .symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: symbolEffectTrigger)
Text(title)
.font(.caption)
} }
} }
} }
// MARK: - Button Styles // MARK: - Button Styles
/// Button style for action buttons (quality, captions, info). /// Button style for action buttons (settings, info, transport, queue).
/// Width is adaptive so localized labels fit when revealed on focus.
struct TVActionButtonStyle: ButtonStyle { struct TVActionButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused @Environment(\.isFocused) private var isFocused
@@ -307,7 +347,8 @@ struct TVActionButtonStyle: ButtonStyle {
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.8) .minimumScaleFactor(0.8)
.frame(width: 140, height: 80) .padding(.horizontal, 20)
.frame(minWidth: 100, minHeight: 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))
@@ -318,4 +359,45 @@ struct TVActionButtonStyle: ButtonStyle {
} }
} }
/// Circular icon-only button style for transport controls (previous / play-pause / next).
/// Primary variant is larger and uses a filled white background when focused.
struct TVTransportButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
var isPrimary: Bool = false
func makeBody(configuration: Configuration) -> some View {
let size: CGFloat = isPrimary ? 88 : 72
return configuration.label
.frame(width: size, height: size)
.background(
Circle()
.fill(isFocused
? (isPrimary ? .white.opacity(0.95) : .white.opacity(0.3))
: (isPrimary ? .white.opacity(0.25) : .white.opacity(0.12)))
)
.foregroundStyle(isFocused && isPrimary ? Color.black : .white)
.scaleEffect(configuration.isPressed ? 0.92 : (isFocused ? 1.08 : 1.0))
.animation(.easeInOut(duration: 0.15), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
/// Compact circular button style for the top-right close affordance.
struct TVCloseButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(.white)
.frame(width: 64, height: 64)
.background(
Circle()
.fill(isFocused ? .white.opacity(0.3) : .white.opacity(0.12))
)
.scaleEffect(configuration.isPressed ? 0.92 : (isFocused ? 1.08 : 1.0))
.animation(.easeInOut(duration: 0.15), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
#endif #endif

View File

@@ -17,6 +17,7 @@ enum TVPlayerFocusTarget: Hashable {
case commentsButton case commentsButton
case debugButton case debugButton
case playPrevious case playPrevious
case playPauseButton
case playNext case playNext
case closeButton case closeButton
case queueButton case queueButton