Files
yattee/Yattee/Views/Components/LoadStateView.swift
Arkadiusz Fal 70cda3fa45 Offer a delayed Cancel while tvOS external video extraction loads
A screen showing only a spinner has nothing focusable, so a slow or hung
extraction stranded the tvOS remote. Add TVStalledLoadCancelButton, a
Cancel that appears under a loading spinner after three seconds so
normal loads never flash it, and use it for external video extraction.
2026-10-17 21:57:56 +02:00

150 lines
4.0 KiB
Swift

//
// LoadStateView.swift
// Yattee
//
// Reusable components for loading, error, and empty states.
//
import SwiftUI
// MARK: - Loading View
/// A simple loading indicator with optional message and subtext.
struct LoadingView: View {
var message: String? = nil
var subtext: String? = nil
/// tvOS: offered as a Cancel button once loading has taken a while.
var onCancel: (() -> Void)? = nil
var body: some View {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.5)
if let message {
Text(message)
.font(.headline)
}
if let subtext {
Text(subtext)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.padding(.horizontal)
}
#if os(tvOS)
if let onCancel {
TVStalledLoadCancelButton(action: onCancel)
.padding(.top, 24)
}
#endif
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
#if os(tvOS)
// MARK: - Stalled Load Cancel
/// A Cancel button that appears under a tvOS loading spinner once the load has
/// taken a while.
///
/// A screen showing only a spinner has nothing focusable: the focus engine has
/// nowhere to land, the remote cannot reach the sidebar and Menu cannot pop the
/// screen, so a hung request strands the user until it times out. Delayed so
/// a normal load never flashes the button.
struct TVStalledLoadCancelButton: View {
var delay: Duration = .seconds(3)
let action: () -> Void
@State private var isShown = false
var body: some View {
Group {
if isShown {
Button(String(localized: "common.cancel"), action: action)
}
}
.task {
guard (try? await Task.sleep(for: delay)) != nil else { return }
isShown = true
}
}
}
extension View {
/// Puts a delayed Cancel under a tvOS loading spinner. See
/// `TVStalledLoadCancelButton`.
func tvStalledLoadCancel(action: @escaping () -> Void) -> some View {
VStack(spacing: 40) {
self
TVStalledLoadCancelButton(action: action)
}
}
}
#endif
// MARK: - Error State View
/// An error view with icon, message, and action buttons.
struct ErrorStateView: View {
let title: String
let message: String
var onRetry: (() async -> Void)? = nil
var onDismiss: (() -> Void)? = nil
var retryTitle: String = "Try Again"
var dismissTitle: String = "Cancel"
var body: some View {
VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 50))
.foregroundStyle(.yellow)
Text(title)
.font(.headline)
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
VStack(spacing: 12) {
if let onRetry {
Button(retryTitle) {
Task { await onRetry() }
}
.buttonStyle(.borderedProminent)
}
if let onDismiss {
Button(dismissTitle) {
onDismiss()
}
.buttonStyle(.bordered)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Previews
#Preview("Loading") {
LoadingView(message: "Loading video...", subtext: "youtube.com")
}
#Preview("Error") {
ErrorStateView(
title: "Could not load video",
message: "The video is unavailable or has been removed.",
onRetry: { try? await Task.sleep(for: .seconds(1)) },
onDismiss: {}
)
}