From 3e561cc3b55d9215c3864ee42c0f704cb696f28a Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 4 Oct 2026 21:04:19 +0200 Subject: [PATCH] Present error details and Open Link through the shared sheet host Error details were declared in the video info view and the expanded player, and Open Link three times: at the app root for deep links and for the File menu command, and on Home. All of them are now AppSheets; the deep link and menu requests go through the global request, so a deep link that arrives while the player is expanded no longer opens the sheet behind it. The four sign-in modals that were a full-screen cover on tvOS and a sheet elsewhere, each written out twice, use a platformSheet helper. --- Yattee/Extensions/View+PlatformSheet.swift | 23 +++++++++++++++++++ .../Navigation/PresentationRouter.swift | 12 ++++++++++ Yattee/Views/Home/HomeView.swift | 9 +++----- .../Views/Instances/InstanceBrowseView.swift | 10 +------- .../Player/ExpandedPlayerSheet+Overlays.swift | 2 +- Yattee/Views/Player/ExpandedPlayerSheet.swift | 4 ---- Yattee/Views/Settings/AccountsListView.swift | 8 +------ Yattee/Views/Settings/EditSourceView.swift | 10 +------- Yattee/Views/Video/VideoInfoView.swift | 10 ++------ Yattee/YatteeApp.swift | 17 +++----------- 10 files changed, 47 insertions(+), 58 deletions(-) create mode 100644 Yattee/Extensions/View+PlatformSheet.swift diff --git a/Yattee/Extensions/View+PlatformSheet.swift b/Yattee/Extensions/View+PlatformSheet.swift new file mode 100644 index 00000000..a14da7e6 --- /dev/null +++ b/Yattee/Extensions/View+PlatformSheet.swift @@ -0,0 +1,23 @@ +// +// View+PlatformSheet.swift +// Yattee +// +// A modal that is a full-screen cover on tvOS and a sheet elsewhere, for +// forms (sign-in) that need the whole screen on tvOS. +// + +import SwiftUI + +extension View { + func platformSheet( + item: Binding, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Item) -> Content + ) -> some View { + #if os(tvOS) + fullScreenCover(item: item, onDismiss: onDismiss, content: content) + #else + sheet(item: item, onDismiss: onDismiss, content: content) + #endif + } +} diff --git a/Yattee/Services/Navigation/PresentationRouter.swift b/Yattee/Services/Navigation/PresentationRouter.swift index 4fa23c88..5a66c4f2 100644 --- a/Yattee/Services/Navigation/PresentationRouter.swift +++ b/Yattee/Services/Navigation/PresentationRouter.swift @@ -22,6 +22,10 @@ enum AppSheet: Identifiable { case addToPlaylist(Video, halfHeight: Bool = false) /// Play now / queue choices for a tapped video while something is playing. case queueAction(Video, queueSource: QueueSource? = nil) + /// Details of a playback or loading error. + case errorDetails(String) + /// Open a link, optionally prefilled. + case openLink(URL?) /// Continue / start over for a partially watched video. case resumeAction(Video, resumeTime: TimeInterval, onContinue: () -> Void, onStartOver: () -> Void) #if !os(tvOS) @@ -36,6 +40,10 @@ enum AppSheet: Identifiable { return "queue" case .addToPlaylist(let video, _): return "addToPlaylist-\(video.id.id)" + case .errorDetails: + return "errorDetails" + case .openLink: + return "openLink" case .queueAction(let video, _): return "queueAction-\(video.id.id)" case .resumeAction(let video, _, _, _): @@ -122,6 +130,10 @@ private struct AppSheetHost: ViewModifier { case .addToPlaylist(let video, let halfHeight): PlaylistSelectorSheet(video: video) .halfHeightDetents(halfHeight) + case .errorDetails(let message): + ErrorDetailsSheet(errorMessage: message) + case .openLink(let url): + OpenLinkSheet(prefilledURL: url) case .queueAction(let video, let queueSource): QueueActionSheet(video: video, queueSource: queueSource) case .resumeAction(let video, let resumeTime, let onContinue, let onStartOver): diff --git a/Yattee/Views/Home/HomeView.swift b/Yattee/Views/Home/HomeView.swift index 2314dc1a..45c41fc0 100644 --- a/Yattee/Views/Home/HomeView.swift +++ b/Yattee/Views/Home/HomeView.swift @@ -9,6 +9,7 @@ import SwiftUI struct HomeView: View { @Environment(\.appEnvironment) private var appEnvironment + @Environment(\.presentAppSheet) private var presentAppSheet #if os(iOS) @Environment(\.horizontalSizeClass) private var horizontalSizeClass #endif @@ -37,7 +38,6 @@ struct HomeView: View { #if !os(tvOS) && !os(macOS) @State private var showingSettings = false #endif - @State private var showingOpenLink = false @State private var showingRemoteControl = false @State private var showingCustomizeHome = false @State private var channelsCount: Int = 0 @@ -201,9 +201,6 @@ struct HomeView: View { showingSettings = false } #endif - .sheet(isPresented: $showingOpenLink) { - OpenLinkSheet() - } .sheet(isPresented: $showingRemoteControl) { RemoteDevicesSheet() } @@ -712,7 +709,7 @@ struct HomeView: View { private var openURLShortcutCard: some View { Button { - showingOpenLink = true + presentAppSheet(.openLink(nil)) } label: { HomeShortcutCardView( icon: "link", @@ -974,7 +971,7 @@ struct HomeView: View { private var openURLShortcutRow: some View { Button { - showingOpenLink = true + presentAppSheet(.openLink(nil)) } label: { HomeShortcutRowView( icon: "link", diff --git a/Yattee/Views/Instances/InstanceBrowseView.swift b/Yattee/Views/Instances/InstanceBrowseView.swift index d1713219..ec2c9a90 100644 --- a/Yattee/Views/Instances/InstanceBrowseView.swift +++ b/Yattee/Views/Instances/InstanceBrowseView.swift @@ -576,19 +576,11 @@ struct InstanceBrowseView: View { Text(accounts) } } - #if os(tvOS) - .fullScreenCover(item: $loginTarget) { target in + .platformSheet(item: $loginTarget) { target in InstanceLoginView(instance: instance, initialUsername: storedUsername(for: target)) { credential, username in storeLogin(credential: credential, username: username, target: target) } } - #else - .sheet(item: $loginTarget) { target in - InstanceLoginView(instance: instance, initialUsername: storedUsername(for: target)) { credential, username in - storeLogin(credential: credential, username: username, target: target) - } - } - #endif .confirmationDialog( String(localized: "accounts.remove.confirm.title \(pendingSignOut.map(accountDisplayName) ?? instance.displayName)"), isPresented: Binding( diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Overlays.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Overlays.swift index b50e94ed..b4e9f4af 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Overlays.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Overlays.swift @@ -109,7 +109,7 @@ extension ExpandedPlayerSheet { VStack(spacing: 16) { // Error details button Button { - showingErrorSheet = true + presentSheet(.errorDetails(playerState?.errorMessage ?? "Unknown error")) } label: { Label(String(localized: "player.error.button"), systemImage: "info.circle") .font(.subheadline.weight(.medium)) diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index cb429523..7ec08029 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -19,7 +19,6 @@ struct ExpandedPlayerSheet: View { @State var showingQualitySheet = false @State var showingDeleteDownloadAlert = false - @State var showingErrorSheet = false @State var isCommentsExpanded: Bool = false @State var commentsDismissOffset: CGFloat = 0 /// Direct scroll position control - use scrollTo(y:) to scroll to top @@ -490,9 +489,6 @@ struct ExpandedPlayerSheet: View { } } .animation(.spring(response: 0.3, dampingFraction: 0.7), value: commentsState == .loaded && !comments.isEmpty) - .sheet(isPresented: $showingErrorSheet) { - ErrorDetailsSheet(errorMessage: playerState?.errorMessage ?? "Unknown error") - } // Host the resolved/ambiguous link confirmation dialogs while the // expanded player is up so they appear above this sheet rather than // being buried underneath it on the root app view. diff --git a/Yattee/Views/Settings/AccountsListView.swift b/Yattee/Views/Settings/AccountsListView.swift index 77020ba0..9d50cf8f 100644 --- a/Yattee/Views/Settings/AccountsListView.swift +++ b/Yattee/Views/Settings/AccountsListView.swift @@ -79,15 +79,9 @@ struct AccountsListView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif - #if os(tvOS) - .fullScreenCover(item: $login) { login in + .platformSheet(item: $login) { login in loginSheet(login) } - #else - .sheet(item: $login) { login in - loginSheet(login) - } - #endif .confirmationDialog( pendingRemoval.map { String(localized: "accounts.remove.confirm.title \($0.name)") } ?? "", isPresented: Binding( diff --git a/Yattee/Views/Settings/EditSourceView.swift b/Yattee/Views/Settings/EditSourceView.swift index c2f7bdc0..2f901d0a 100644 --- a/Yattee/Views/Settings/EditSourceView.swift +++ b/Yattee/Views/Settings/EditSourceView.swift @@ -353,19 +353,11 @@ private struct EditRemoteServerContent: View { } } .presentationCompactAdaptation(.sheet) - #if os(tvOS) - .fullScreenCover(item: $loginTarget) { target in + .platformSheet(item: $loginTarget) { target in InstanceLoginView(instance: instance, initialUsername: storedUsername(for: target)) { credential, username in storeLogin(credential: credential, username: username, target: target) } } - #else - .sheet(item: $loginTarget) { target in - InstanceLoginView(instance: instance, initialUsername: storedUsername(for: target)) { credential, username in - storeLogin(credential: credential, username: username, target: target) - } - } - #endif // Signing out happens in the accounts section below, which cannot reach // this state directly. Without this the authenticated import actions // stay on screen after the last account is removed, and then fail. diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index 666d554a..d2a3a439 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -45,7 +45,6 @@ struct VideoInfoView: View { @State private var loadMoreError: String? @State private var isBookmarked = false - @State private var showingErrorDetailsSheet = false @State private var showingCommentsSheet = false @State private var showingRemoveBookmarkAlert = false @State private var currentBookmark: Bookmark? @@ -437,11 +436,6 @@ struct VideoInfoView: View { await loadVideoDetails() } } - #if !os(tvOS) - .sheet(isPresented: $showingErrorDetailsSheet) { - ErrorDetailsSheet(errorMessage: videoDetailsLoadError ?? initialVideoLoadError ?? "Unknown error") - } - #endif #if os(tvOS) .fullScreenCover(isPresented: $showingCommentsSheet) { commentsSheetContent @@ -878,7 +872,7 @@ struct VideoInfoView: View { #if !os(tvOS) Button { - showingErrorDetailsSheet = true + presentAppSheet(.errorDetails(videoDetailsLoadError ?? initialVideoLoadError ?? "Unknown error")) } label: { Label(String(localized: "player.error.button"), systemImage: "info.circle") } @@ -1977,7 +1971,7 @@ struct VideoInfoView: View { #if !os(tvOS) Button { - showingErrorDetailsSheet = true + presentAppSheet(.errorDetails(videoDetailsLoadError ?? initialVideoLoadError ?? "Unknown error")) } label: { Label(String(localized: "player.error.button"), systemImage: "info.circle") } diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index 07dcd267..75f2defa 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -38,7 +38,6 @@ struct YatteeApp: App { @Environment(\.scenePhase) private var scenePhase // Deep link handling state - @State private var prefilledLinkURL: URL? #if !os(tvOS) @State private var deepLinkVideo: Video? @State private var deepLinkStreams: [Stream] = [] @@ -51,7 +50,6 @@ struct YatteeApp: App { @State private var showingICloudProgress = false @State private var showingLegacyAccountsAlert = false @State private var showingLegacyAccountsImport = false - @State private var showingOpenLinkSheet = false // Ambiguous-link prompts are stored on NavigationCoordinator so they can // be dual-hosted by the root app view (here) and the expanded player sheet. @@ -119,10 +117,6 @@ struct YatteeApp: App { Text(String(localized: "alert.openVideo.message \(url.host ?? "clipboard")")) } } - .sheet(item: $prefilledLinkURL) { url in - OpenLinkSheet(prefilledURL: url) - .appEnvironment(appEnvironment) - } #if !os(tvOS) .sheet(isPresented: $showingDeepLinkDownloadSheet) { if let video = deepLinkVideo { @@ -202,15 +196,9 @@ struct YatteeApp: App { #endif } #endif - #if !os(tvOS) - .sheet(isPresented: $showingOpenLinkSheet) { - OpenLinkSheet() - .appEnvironment(appEnvironment) - } - #endif .onReceive(NotificationCenter.default.publisher(for: .showOpenLinkSheet)) { _ in appEnvironment.navigationCoordinator.isPlayerExpanded = false - showingOpenLinkSheet = true + appEnvironment.navigationCoordinator.presenter.present(.openLink(nil)) } .onReceive(NotificationCenter.default.publisher(for: .openDescriptionLink)) { notification in if let url = notification.object as? URL { @@ -645,7 +633,8 @@ struct YatteeApp: App { /// Show OpenLinkSheet with a pre-filled URL. private func showOpenLinkSheetWithURL(_ url: URL) { - prefilledLinkURL = url + // The top-most sheet host (the player when expanded) presents it + appEnvironment.navigationCoordinator.presenter.present(.openLink(url)) } /// Play a video from a deep link.