mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
Show playback failure overlay on tvOS
Previously a failed video left the user staring at a black screen / thumbnail with no indication anything went wrong — playbackState went to .failed but TVPlayerView never read it. Add a focusable glass overlay (Details / Retry / Play Next or Close) gated on isFailed and a parallel one for retry-exhausted state, with the regular controls and background tap-target disabled while either is visible so focus stays inside the overlay. Hide the Copy/Share toolbar items in ErrorDetailsSheet on tvOS where they aren't useful.
This commit is contained in:
@@ -113,6 +113,7 @@ struct ErrorDetailsSheet: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
#if !os(tvOS)
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button(role: .cancel) {
|
Button(role: .cancel) {
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -131,14 +132,13 @@ struct ErrorDetailsSheet: View {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel(String(localized: "player.error.copy.accessibilityLabel"))
|
.accessibilityLabel(String(localized: "player.error.copy.accessibilityLabel"))
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
// Share button (not available on tvOS)
|
// Share button (not available on tvOS)
|
||||||
ShareLink(item: errorMessage) {
|
ShareLink(item: errorMessage) {
|
||||||
Label(String(localized: "player.error.share"), systemImage: "square.and.arrow.up")
|
Label(String(localized: "player.error.share"), systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
.accessibilityLabel(String(localized: "player.error.share.accessibilityLabel"))
|
.accessibilityLabel(String(localized: "player.error.share.accessibilityLabel"))
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ enum TVPlayerFocusTarget: Hashable {
|
|||||||
case playNext
|
case playNext
|
||||||
case closeButton
|
case closeButton
|
||||||
case queueButton
|
case queueButton
|
||||||
|
case errorDetails
|
||||||
|
case errorRetry
|
||||||
|
case errorPlayNext
|
||||||
|
case errorClose
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Main tvOS fullscreen player view.
|
/// Main tvOS fullscreen player view.
|
||||||
@@ -51,6 +55,9 @@ struct TVPlayerView: View {
|
|||||||
/// Whether the queue sheet is shown.
|
/// Whether the queue sheet is shown.
|
||||||
@State private var showingQueueSheet = false
|
@State private var showingQueueSheet = false
|
||||||
|
|
||||||
|
/// Whether the error details sheet is shown.
|
||||||
|
@State private var showingErrorSheet = false
|
||||||
|
|
||||||
/// Whether the debug overlay is shown.
|
/// Whether the debug overlay is shown.
|
||||||
@State private var isDebugOverlayVisible = false
|
@State private var isDebugOverlayVisible = false
|
||||||
|
|
||||||
@@ -123,6 +130,17 @@ struct TVPlayerView: View {
|
|||||||
QueueManagementSheet()
|
QueueManagementSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.fullScreenCover(isPresented: $showingErrorSheet) {
|
||||||
|
ZStack {
|
||||||
|
Rectangle()
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
ErrorDetailsSheet(errorMessage: playerState?.errorMessage ?? "Unknown error")
|
||||||
|
.frame(maxWidth: 1200, maxHeight: 700)
|
||||||
|
.padding(.horizontal, 200)
|
||||||
|
.padding(.vertical, 80)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Quality Sheet Content
|
// MARK: - Quality Sheet Content
|
||||||
@@ -302,7 +320,18 @@ struct TVPlayerView: View {
|
|||||||
)
|
)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Playback failure overlay
|
||||||
|
if playerState?.isFailed == true {
|
||||||
|
failedOverlay
|
||||||
|
.transition(.opacity)
|
||||||
|
} else if playerState?.retryState.exhausted == true {
|
||||||
|
retryExhaustedOverlay
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: playerState?.isFailed)
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: playerState?.retryState.exhausted)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
startControlsTimer()
|
startControlsTimer()
|
||||||
focusedControl = .progressBar
|
focusedControl = .progressBar
|
||||||
@@ -333,6 +362,13 @@ struct TVPlayerView: View {
|
|||||||
startControlsTimer()
|
startControlsTimer()
|
||||||
} else if newState == .ended {
|
} else if newState == .ended {
|
||||||
handleVideoEnded()
|
handleVideoEnded()
|
||||||
|
} else if case .failed = newState {
|
||||||
|
handleVideoFailed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: playerState?.retryState.exhausted) { _, exhausted in
|
||||||
|
if exhausted == true {
|
||||||
|
handleVideoFailed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Dismiss countdown if video changes during countdown (e.g., from remote control)
|
// Dismiss countdown if video changes during countdown (e.g., from remote control)
|
||||||
@@ -344,11 +380,156 @@ struct TVPlayerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Failure Overlays
|
||||||
|
|
||||||
|
/// Whether either failure overlay is currently visible.
|
||||||
|
private var isFailureOverlayVisible: Bool {
|
||||||
|
playerState?.isFailed == true || playerState?.retryState.exhausted == true
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var failedOverlay: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.55)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 36) {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 64, weight: .semibold))
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
if let message = playerState?.errorMessage, !message.isEmpty {
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 24))
|
||||||
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(3)
|
||||||
|
.frame(maxWidth: 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 60)
|
||||||
|
.padding(.vertical, 36)
|
||||||
|
.glassBackground(.regular, in: .rect(cornerRadius: 28), fallback: .ultraThinMaterial)
|
||||||
|
|
||||||
|
HStack(spacing: 32) {
|
||||||
|
failureButton(
|
||||||
|
title: String(localized: "player.error.button"),
|
||||||
|
systemImage: "info.circle",
|
||||||
|
focus: .errorDetails,
|
||||||
|
action: { showingErrorSheet = true }
|
||||||
|
)
|
||||||
|
|
||||||
|
failureButton(
|
||||||
|
title: String(localized: "player.error.retry"),
|
||||||
|
systemImage: "arrow.clockwise",
|
||||||
|
focus: .errorRetry,
|
||||||
|
action: { retryPlayback() }
|
||||||
|
)
|
||||||
|
|
||||||
|
if playerState?.nextQueuedVideo != nil {
|
||||||
|
failureButton(
|
||||||
|
title: String(localized: "player.autoplay.playNext"),
|
||||||
|
systemImage: "forward.fill",
|
||||||
|
focus: .errorPlayNext,
|
||||||
|
action: { playNextInQueue() }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
failureButton(
|
||||||
|
title: String(localized: "player.close"),
|
||||||
|
systemImage: "xmark",
|
||||||
|
focus: .errorClose,
|
||||||
|
action: { closeVideo() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusSection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var retryExhaustedOverlay: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.55)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 36) {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "arrow.clockwise.circle")
|
||||||
|
.font(.system(size: 56, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
|
Text(String(localized: "player.retry.button"))
|
||||||
|
.font(.system(size: 32, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 32) {
|
||||||
|
failureButton(
|
||||||
|
title: String(localized: "player.error.retry"),
|
||||||
|
systemImage: "arrow.clockwise",
|
||||||
|
focus: .errorRetry,
|
||||||
|
action: { retryPlayback() }
|
||||||
|
)
|
||||||
|
|
||||||
|
failureButton(
|
||||||
|
title: String(localized: "player.close"),
|
||||||
|
systemImage: "xmark",
|
||||||
|
focus: .errorClose,
|
||||||
|
action: { closeVideo() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.focusSection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func failureButton(
|
||||||
|
title: String,
|
||||||
|
systemImage: String,
|
||||||
|
focus: TVPlayerFocusTarget,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Label(title, systemImage: systemImage)
|
||||||
|
.font(.system(size: 22, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 320, height: 80)
|
||||||
|
}
|
||||||
|
.buttonStyle(TVFailureButtonStyle())
|
||||||
|
.focused($focusedControl, equals: focus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Failure Actions
|
||||||
|
|
||||||
|
/// Restart playback of the current video from scratch.
|
||||||
|
private func retryPlayback() {
|
||||||
|
guard let playerService, let video = playerState?.currentVideo else { return }
|
||||||
|
Task {
|
||||||
|
await playerService.play(video: video)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when playback enters the failed state or retries are exhausted.
|
||||||
|
private func handleVideoFailed() {
|
||||||
|
stopControlsTimer()
|
||||||
|
stopAutoplayCountdown()
|
||||||
|
withAnimation(.easeOut(duration: 0.25)) {
|
||||||
|
controlsVisible = false
|
||||||
|
}
|
||||||
|
// Defer focus assignment so the overlay is in the tree before the focus
|
||||||
|
// engine evaluates it.
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(50))
|
||||||
|
focusedControl = .errorRetry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Background Layer
|
// MARK: - Background Layer
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var backgroundLayer: some View {
|
private var backgroundLayer: some View {
|
||||||
if !controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible {
|
if !controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible && !isFailureOverlayVisible {
|
||||||
// When controls hidden, use a Button to capture both click and swipe
|
// When controls hidden, use a Button to capture both click and swipe
|
||||||
Button {
|
Button {
|
||||||
showControls()
|
showControls()
|
||||||
@@ -413,7 +594,7 @@ struct TVPlayerView: View {
|
|||||||
|
|
||||||
/// Whether the primary controls overlay should be visible right now.
|
/// Whether the primary controls overlay should be visible right now.
|
||||||
private var shouldShowControls: Bool {
|
private var shouldShowControls: Bool {
|
||||||
controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible
|
controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible && !isFailureOverlayVisible
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Controls Timer
|
// MARK: - Controls Timer
|
||||||
@@ -682,7 +863,14 @@ struct TVPlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleMenuButton() {
|
private func handleMenuButton() {
|
||||||
if showAutoplayCountdown {
|
if showingErrorSheet {
|
||||||
|
// Top priority: close the error details sheet
|
||||||
|
showingErrorSheet = false
|
||||||
|
} else if isFailureOverlayVisible {
|
||||||
|
// While the failure overlay is up, Menu closes the video so the
|
||||||
|
// user isn't stranded with no working remote affordance.
|
||||||
|
closeVideo()
|
||||||
|
} else if showAutoplayCountdown {
|
||||||
// First priority: cancel countdown
|
// First priority: cancel countdown
|
||||||
cancelAutoplay()
|
cancelAutoplay()
|
||||||
} else if isDebugOverlayVisible {
|
} else if isDebugOverlayVisible {
|
||||||
@@ -816,4 +1004,22 @@ struct TVBackgroundButtonStyle: ButtonStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Glass-backed button style used by the playback failure overlay.
|
||||||
|
/// Scales on focus and brightens the glass material to indicate selection.
|
||||||
|
struct TVFailureButtonStyle: ButtonStyle {
|
||||||
|
@Environment(\.isFocused) private var isFocused
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.glassBackground(
|
||||||
|
isFocused ? .tinted(.white.opacity(0.25)) : .regular,
|
||||||
|
in: .capsule,
|
||||||
|
fallback: isFocused ? .ultraThickMaterial : .ultraThinMaterial
|
||||||
|
)
|
||||||
|
.scaleEffect(configuration.isPressed ? 0.95 : (isFocused ? 1.08 : 1.0))
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: isFocused)
|
||||||
|
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
Reference in New Issue
Block a user