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:
Arkadiusz Fal
2026-05-07 18:29:48 +02:00
parent 158d518e3a
commit c8bb13e229
2 changed files with 211 additions and 5 deletions

View File

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

View File

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