mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +00:00
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:
@@ -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" : {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user