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.
This commit is contained in:
Arkadiusz Fal
2026-10-04 21:04:19 +02:00
parent e3d3809f2f
commit 3e561cc3b5
10 changed files with 47 additions and 58 deletions

View File

@@ -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: Identifiable, Content: View>(
item: Binding<Item?>,
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
}
}

View File

@@ -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):

View File

@@ -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",

View File

@@ -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(

View File

@@ -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))

View File

@@ -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.

View File

@@ -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(

View File

@@ -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.

View File

@@ -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")
}

View File

@@ -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.