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.
This commit is contained in:
Arkadiusz Fal
2026-10-17 21:57:56 +02:00
parent b613b8dbc8
commit 70cda3fa45
2 changed files with 52 additions and 1 deletions

View File

@@ -13,6 +13,8 @@ import SwiftUI
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) {
@@ -32,11 +34,59 @@ struct LoadingView: View {
.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.

View File

@@ -24,7 +24,8 @@ struct ExternalVideoView: View {
if isLoading {
LoadingView(
message: String(localized: "externalVideo.extracting"),
subtext: url.host ?? url.absoluteString
subtext: url.host ?? url.absoluteString,
onCancel: { dismiss() }
)
} else if let error = errorMessage {
ErrorStateView(