From 70cda3fa458879a59ac44000352f6f5a7f820405 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 17 Oct 2026 21:57:56 +0200 Subject: [PATCH] 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. --- Yattee/Views/Components/LoadStateView.swift | 50 +++++++++++++++++++++ Yattee/Views/Video/ExternalVideoView.swift | 3 +- 2 files changed, 52 insertions(+), 1 deletion(-) 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(