diff --git a/Yattee/Views/Components/LoadStateView.swift b/Yattee/Views/Components/LoadStateView.swift index ec508bad..a7723ee9 100644 --- a/Yattee/Views/Components/LoadStateView.swift +++ b/Yattee/Views/Components/LoadStateView.swift @@ -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. diff --git a/Yattee/Views/Video/ExternalVideoView.swift b/Yattee/Views/Video/ExternalVideoView.swift index ab9d862d..08c22960 100644 --- a/Yattee/Views/Video/ExternalVideoView.swift +++ b/Yattee/Views/Video/ExternalVideoView.swift @@ -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(