From e3d3809f2f80a3dd8d0a9561b91a51f077bbdcd5 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 4 Oct 2026 09:07:05 +0200 Subject: [PATCH] Present row sheets through the shared sheet host Each video row carried its own sheets: tappableVideo added the queue action and resume sheets, the context menu and swipe actions added add-to-playlist and download options, so a row could declare up to six sheets, owned by a row that a list may recycle. Rows now present those through the nearest sheet host, as does the video info view's resume prompt. The queue action sheet for a tapped description link becomes a global request instead of being declared at both the app root and the expanded player. Sheets take the color scheme where their host sits, so the floating details panel's dark override no longer reaches them and the presentationColorScheme workaround is gone. --- .../Navigation/NavigationCoordinator.swift | 8 +-- .../Navigation/PresentationRouter.swift | 19 ++++--- .../ResolvedLinkPromptsModifier.swift | 3 +- .../Components/TappableVideoModifier.swift | 56 +++---------------- .../Views/Components/VideoContextMenu.swift | 35 ++---------- .../VideoSwipeActionsModifier.swift | 16 ++---- Yattee/Views/Player/ExpandedPlayerSheet.swift | 13 ----- .../Views/Player/FloatingDetailsPanel.swift | 1 - Yattee/Views/Video/VideoInfoView.swift | 16 ++---- Yattee/YatteeApp.swift | 19 +------ 10 files changed, 39 insertions(+), 147 deletions(-) diff --git a/Yattee/Services/Navigation/NavigationCoordinator.swift b/Yattee/Services/Navigation/NavigationCoordinator.swift index 129b32d4..89f7e23b 100644 --- a/Yattee/Services/Navigation/NavigationCoordinator.swift +++ b/Yattee/Services/Navigation/NavigationCoordinator.swift @@ -63,14 +63,8 @@ final class NavigationCoordinator { /// (remote control state) reads the real layout instead of screen bounds. var isWideScreenLayoutActive = false - /// Video to present in the queue action sheet (e.g. a video link tapped inside - /// a description). Set by the deep-link handler; cleared when the sheet dismisses. - /// Presented by YatteeApp when the player is collapsed and by ExpandedPlayerSheet - /// when the player is expanded, so the sheet always sits above the visible layer. - var descriptionLinkQueueSheetVideo: Video? - /// Resolved short-link URL awaiting user confirmation ("Try in Yattee" vs - /// "Open in Browser"). Dual-hosted like `descriptionLinkQueueSheetVideo` + /// "Open in Browser"). Dual-hosted (root and expanded player) /// so the dialog is visible whether or not the expanded player is covering /// the main app view. var resolvedShortLinkPrompt: URL? diff --git a/Yattee/Services/Navigation/PresentationRouter.swift b/Yattee/Services/Navigation/PresentationRouter.swift index 049303aa..4fa23c88 100644 --- a/Yattee/Services/Navigation/PresentationRouter.swift +++ b/Yattee/Services/Navigation/PresentationRouter.swift @@ -20,6 +20,10 @@ enum AppSheet: Identifiable { case queue(halfHeight: Bool = false) /// Add a video to a playlist. `halfHeight` opens at the medium detent. case addToPlaylist(Video, halfHeight: Bool = false) + /// Play now / queue choices for a tapped video while something is playing. + case queueAction(Video, queueSource: QueueSource? = nil) + /// Continue / start over for a partially watched video. + case resumeAction(Video, resumeTime: TimeInterval, onContinue: () -> Void, onStartOver: () -> Void) #if !os(tvOS) /// Download quality picker. Streams and captions already loaded by the /// caller (the player) skip the sheet's own fetch. @@ -32,6 +36,10 @@ enum AppSheet: Identifiable { return "queue" case .addToPlaylist(let video, _): return "addToPlaylist-\(video.id.id)" + case .queueAction(let video, _): + return "queueAction-\(video.id.id)" + case .resumeAction(let video, _, _, _): + return "resumeAction-\(video.id.id)" #if !os(tvOS) case .downloadOptions(let video, _, _, _): return "downloadOptions-\(video.id.id)" @@ -85,16 +93,9 @@ private struct AppSheetHost: ViewModifier { func body(content: Content) -> some View { content .environment(\.presentAppSheet, PresentAppSheetAction { sheet = $0 }) - #if os(tvOS) - .fullScreenCover(item: $sheet) { sheet in - sheetContent(for: sheet) - .tvFullScreenCoverBackground() - } - #else .sheet(item: $sheet) { sheet in sheetContent(for: sheet) } - #endif .onChange(of: pendingRequestID, initial: true) { _, _ in takePendingRequest() } @@ -121,6 +122,10 @@ private struct AppSheetHost: ViewModifier { case .addToPlaylist(let video, let halfHeight): PlaylistSelectorSheet(video: video) .halfHeightDetents(halfHeight) + case .queueAction(let video, let queueSource): + QueueActionSheet(video: video, queueSource: queueSource) + case .resumeAction(let video, let resumeTime, let onContinue, let onStartOver): + ResumeActionSheet(video: video, resumeTime: resumeTime, onContinue: onContinue, onStartOver: onStartOver) #if !os(tvOS) case .downloadOptions(let video, let streams, let captions, let dislikeCount): DownloadQualitySheet(video: video, streams: streams, captions: captions, dislikeCount: dislikeCount) diff --git a/Yattee/Views/Components/ResolvedLinkPromptsModifier.swift b/Yattee/Views/Components/ResolvedLinkPromptsModifier.swift index 0048c2fc..b5460d39 100644 --- a/Yattee/Views/Components/ResolvedLinkPromptsModifier.swift +++ b/Yattee/Views/Components/ResolvedLinkPromptsModifier.swift @@ -13,8 +13,7 @@ // The state lives on `NavigationCoordinator` so both the root app view and // `ExpandedPlayerSheet` can host the dialogs. Only whichever host is currently // on top presents the dialog (`shouldHost`), so the dialog is visible whether -// the expanded player is covering the main view or not — matching the -// `descriptionLinkQueueSheetVideo` pattern. +// the expanded player is covering the main view or not. // import SwiftUI diff --git a/Yattee/Views/Components/TappableVideoModifier.swift b/Yattee/Views/Components/TappableVideoModifier.swift index 45e0d846..f7761b1b 100644 --- a/Yattee/Views/Components/TappableVideoModifier.swift +++ b/Yattee/Views/Components/TappableVideoModifier.swift @@ -7,37 +7,12 @@ import SwiftUI -/// Data for the resume action sheet, used with sheet(item:) to ensure data availability. -struct ResumeSheetData: Identifiable { - let id = UUID() - let video: Video - let resumeTime: TimeInterval -} - /// View modifier that wraps content in a button that plays a video when tapped. /// When queue is enabled and not empty, shows a sheet with queue options. /// When queue is empty or disabled, plays the video directly and queues subsequent videos. -// MARK: - Presentation Color Scheme - -/// The real system color scheme, published by hosts that override -/// `colorScheme` for their own content (the unpinned floating details panel -/// renders dark over video). Sheets presented from inside such a host use it -/// so they don't get dark text colors on a light system sheet background. -private struct PresentationColorSchemeKey: EnvironmentKey { - static let defaultValue: ColorScheme? = nil -} - -extension EnvironmentValues { - var presentationColorScheme: ColorScheme? { - get { self[PresentationColorSchemeKey.self] } - set { self[PresentationColorSchemeKey.self] = newValue } - } -} - struct TappableVideoModifier: ViewModifier { @Environment(\.appEnvironment) private var appEnvironment - @Environment(\.colorScheme) private var colorScheme - @Environment(\.presentationColorScheme) private var presentationColorScheme + @Environment(\.presentAppSheet) private var presentAppSheet let video: Video var startTime: Double? = nil @@ -56,11 +31,6 @@ struct TappableVideoModifier: ViewModifier { /// Callback to load more videos via continuation nonisolated(unsafe) var loadMoreVideos: LoadMoreVideosCallback? = nil - @State private var showingQueueSheet = false - - // Resume action sheet state - using item-based sheet to ensure data is available when presented - @State private var resumeSheetData: ResumeSheetData? = nil - // Password alert state (for WebDAV sources) @State private var showingPasswordAlert = false @State private var sourceNeedingPassword: MediaSource? @@ -102,19 +72,6 @@ struct TappableVideoModifier: ViewModifier { startTime: startTime ) } - .sheet(isPresented: $showingQueueSheet) { - QueueActionSheet(video: video, queueSource: queueSource) - .environment(\.colorScheme, presentationColorScheme ?? colorScheme) - } - .sheet(item: $resumeSheetData) { data in - ResumeActionSheet( - video: data.video, - resumeTime: data.resumeTime, - onContinue: { playVideoWithStartTime(data.resumeTime) }, - onStartOver: { playVideoWithStartTime(0) } - ) - .environment(\.colorScheme, presentationColorScheme ?? colorScheme) - } .alert(String(localized: "common.authenticationRequired"), isPresented: $showingPasswordAlert) { SecureField(String(localized: "common.password"), text: $passwordInput) Button(String(localized: "common.cancel"), role: .cancel) { @@ -165,7 +122,7 @@ struct TappableVideoModifier: ViewModifier { sourceNeedingPassword = source showingPasswordAlert = true } else if shouldShowQueueSheet { - showingQueueSheet = true + presentAppSheet(.queueAction(video, queueSource: queueSource)) } else { playVideoAndQueueRest() } @@ -180,7 +137,7 @@ struct TappableVideoModifier: ViewModifier { // Now check if we should show queue sheet or play directly if shouldShowQueueSheet { - showingQueueSheet = true + presentAppSheet(.queueAction(video, queueSource: queueSource)) } else { playVideoAndQueueRest() } @@ -229,7 +186,12 @@ struct TappableVideoModifier: ViewModifier { playVideoWithStartTime(0) case .ask: // Show the resume action sheet with data bundled together - resumeSheetData = ResumeSheetData(video: video, resumeTime: savedProgress) + presentAppSheet(.resumeAction( + video, + resumeTime: savedProgress, + onContinue: { playVideoWithStartTime(savedProgress) }, + onStartOver: { playVideoWithStartTime(0) } + )) } } else { // No saved progress or video was completed - play from beginning diff --git a/Yattee/Views/Components/VideoContextMenu.swift b/Yattee/Views/Components/VideoContextMenu.swift index 54984a6f..2454465c 100644 --- a/Yattee/Views/Components/VideoContextMenu.swift +++ b/Yattee/Views/Components/VideoContextMenu.swift @@ -49,8 +49,6 @@ struct VideoContextMenuModifier: ViewModifier { var startTime: Double? = nil var watchProgress: Double? = nil - @State private var showingPlaylistSheet = false - @State private var showingDownloadSheet = false @State private var showingDeleteDownloadConfirmation = false @State private var downloadToDelete: Download? @State private var menuRefreshID = UUID() @@ -64,8 +62,6 @@ struct VideoContextMenuModifier: ViewModifier { customActions: customActions, context: context, startTime: startTime, - showingPlaylistSheet: $showingPlaylistSheet, - showingDownloadSheet: $showingDownloadSheet, showingDeleteDownloadConfirmation: $showingDeleteDownloadConfirmation, downloadToDelete: $downloadToDelete, appEnvironment: appEnvironment @@ -79,8 +75,6 @@ struct VideoContextMenuModifier: ViewModifier { customActions: customActions, context: context, startTime: startTime, - showingPlaylistSheet: $showingPlaylistSheet, - showingDownloadSheet: $showingDownloadSheet, showingDeleteDownloadConfirmation: $showingDeleteDownloadConfirmation, downloadToDelete: $downloadToDelete, appEnvironment: appEnvironment @@ -98,13 +92,7 @@ struct VideoContextMenuModifier: ViewModifier { .environment(\.appEnvironment, appEnvironment) }) #endif - .sheet(isPresented: $showingPlaylistSheet) { - PlaylistSelectorSheet(video: video) - } #if !os(tvOS) - .sheet(isPresented: $showingDownloadSheet) { - DownloadQualitySheet(video: video) - } .alert(String(localized: "videoInfo.download.remove.title"), isPresented: $showingDeleteDownloadConfirmation) { Button(String(localized: "common.cancel"), role: .cancel) {} Button(String(localized: "videoInfo.download.remove.confirm"), role: .destructive) { @@ -130,13 +118,12 @@ struct VideoContextMenuModifier: ViewModifier { /// All observable values are snapshotted at init time to prevent redraws during playback. struct VideoContextMenuContent: View { @Environment(\.appEnvironment) private var appEnvironment + @Environment(\.presentAppSheet) private var presentAppSheet let video: Video var customActions: [VideoContextAction] = [] var context: VideoContextMenuContext = .default var startTime: Double? = nil - @Binding var showingPlaylistSheet: Bool - @Binding var showingDownloadSheet: Bool @Binding var showingDeleteDownloadConfirmation: Bool @Binding var downloadToDelete: Download? @@ -168,8 +155,6 @@ struct VideoContextMenuContent: View { customActions: [VideoContextAction] = [], context: VideoContextMenuContext = .default, startTime: Double? = nil, - showingPlaylistSheet: Binding, - showingDownloadSheet: Binding, showingDeleteDownloadConfirmation: Binding = .constant(false), downloadToDelete: Binding = .constant(nil), appEnvironment: AppEnvironment? = nil @@ -178,8 +163,6 @@ struct VideoContextMenuContent: View { self.customActions = customActions self.context = context self.startTime = startTime - self._showingPlaylistSheet = showingPlaylistSheet - self._showingDownloadSheet = showingDownloadSheet self._showingDeleteDownloadConfirmation = showingDeleteDownloadConfirmation self._downloadToDelete = downloadToDelete @@ -427,7 +410,7 @@ struct VideoContextMenuContent: View { // Add to playlist if showAddToPlaylist { Button { - showingPlaylistSheet = true + presentAppSheet(.addToPlaylist(video)) } label: { Label(String(localized: "video.context.addToPlaylist"), systemImage: "text.badge.plus") } @@ -440,7 +423,7 @@ struct VideoContextMenuContent: View { /// Starts a download either automatically or by showing the quality sheet. private func startDownload(for video: Video) { guard let appEnvironment else { - showingDownloadSheet = true + presentAppSheet(.downloadOptions(video)) return } @@ -494,7 +477,7 @@ struct VideoContextMenuContent: View { } } } else { - showingDownloadSheet = true + presentAppSheet(.downloadOptions(video)) } } #endif @@ -613,8 +596,6 @@ struct VideoContextMenuView: View { var buttonBackgroundStyle: ButtonBackgroundStyle = .none var theme: ControlsTheme = .dark - @State private var showingPlaylistSheet = false - @State private var showingDownloadSheet = false @State private var showingDeleteDownloadConfirmation = false @State private var downloadToDelete: Download? @State private var refreshID = UUID() @@ -628,8 +609,6 @@ struct VideoContextMenuView: View { VideoContextMenuContent( video: video, context: .player, - showingPlaylistSheet: $showingPlaylistSheet, - showingDownloadSheet: $showingDownloadSheet, showingDeleteDownloadConfirmation: $showingDeleteDownloadConfirmation, downloadToDelete: $downloadToDelete, appEnvironment: appEnvironment @@ -645,12 +624,6 @@ struct VideoContextMenuView: View { .menuStyle(.borderlessButton) .fixedSize() #endif - .sheet(isPresented: $showingPlaylistSheet) { - PlaylistSelectorSheet(video: video) - } - .sheet(isPresented: $showingDownloadSheet) { - DownloadQualitySheet(video: video) - } .alert(String(localized: "videoInfo.download.remove.title"), isPresented: $showingDeleteDownloadConfirmation) { Button(String(localized: "common.cancel"), role: .cancel) {} Button(String(localized: "videoInfo.download.remove.confirm"), role: .destructive) { diff --git a/Yattee/Views/Components/VideoSwipeActionsModifier.swift b/Yattee/Views/Components/VideoSwipeActionsModifier.swift index f47a6fdc..2af22f3c 100644 --- a/Yattee/Views/Components/VideoSwipeActionsModifier.swift +++ b/Yattee/Views/Components/VideoSwipeActionsModifier.swift @@ -12,22 +12,14 @@ import SwiftUI struct VideoSwipeActionsModifier: ViewModifier { @Environment(\.appEnvironment) private var appEnvironment @Environment(\.videoQueueContext) private var queueContext + @Environment(\.presentAppSheet) private var presentAppSheet let video: Video var fixedActions: [SwipeAction] = [] - @State private var showingPlaylistSheet = false - @State private var showingDownloadSheet = false - func body(content: Content) -> some View { content .swipeActions(actionsArray: allActions()) - .sheet(isPresented: $showingPlaylistSheet) { - PlaylistSelectorSheet(video: video) - } - .sheet(isPresented: $showingDownloadSheet) { - DownloadQualitySheet(video: video) - } } private var visibleActions: [VideoSwipeAction] { @@ -104,7 +96,7 @@ struct VideoSwipeActionsModifier: ViewModifier { tint: action.tint, background: action.backgroundColor ) { reset in - showingPlaylistSheet = true + presentAppSheet(.addToPlaylist(video)) reset() } @@ -223,7 +215,7 @@ struct VideoSwipeActionsModifier: ViewModifier { private func startDownload() { guard let appEnvironment else { - showingDownloadSheet = true + presentAppSheet(.downloadOptions(video)) return } @@ -277,7 +269,7 @@ struct VideoSwipeActionsModifier: ViewModifier { } } } else { - showingDownloadSheet = true + presentAppSheet(.downloadOptions(video)) } } } diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index 0f7660e9..cb429523 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -493,19 +493,6 @@ struct ExpandedPlayerSheet: View { .sheet(isPresented: $showingErrorSheet) { ErrorDetailsSheet(errorMessage: playerState?.errorMessage ?? "Unknown error") } - .sheet(item: Binding( - get: { appEnvironment?.navigationCoordinator.descriptionLinkQueueSheetVideo }, - set: { newValue in - if newValue == nil { - appEnvironment?.navigationCoordinator.descriptionLinkQueueSheetVideo = nil - } - } - )) { video in - if let appEnvironment { - QueueActionSheet(video: video) - .appEnvironment(appEnvironment) - } - } // 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/Player/FloatingDetailsPanel.swift b/Yattee/Views/Player/FloatingDetailsPanel.swift index fd9582e4..44408ada 100644 --- a/Yattee/Views/Player/FloatingDetailsPanel.swift +++ b/Yattee/Views/Player/FloatingDetailsPanel.swift @@ -154,7 +154,6 @@ struct FloatingDetailsPanel: View { } } .environment(\.colorScheme, isPinned ? systemColorScheme : .dark) - .environment(\.presentationColorScheme, systemColorScheme) .animation(.easeInOut(duration: 0.25), value: isPinned) .onChange(of: video.id) { _, _ in // Comments state is managed by parent - just reset local state diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index 2573581f..666d554a 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -106,7 +106,6 @@ struct VideoInfoView: View { @State private var showingRemoveDownloadConfirmation = false // Resume action sheet state - @State private var resumeSheetData: ResumeSheetData? // Video details cache - stores full video details loaded from API @State private var loadedVideoDetails: [String: Video] = [:] @@ -452,14 +451,6 @@ struct VideoInfoView: View { commentsSheetContent } #endif - .sheet(item: $resumeSheetData) { data in - ResumeActionSheet( - video: data.video, - resumeTime: data.resumeTime, - onContinue: { playVideoWithStartTime(data.resumeTime) }, - onStartOver: { playVideoWithStartTime(0) } - ) - } #if !os(tvOS) .onChange(of: downloadManager?.completedDownloads) { _, newValue in // Update download state when completedDownloads changes @@ -2571,7 +2562,12 @@ struct VideoInfoView: View { playVideoWithStartTime(0) case .ask: // Show the resume action sheet - resumeSheetData = ResumeSheetData(video: video, resumeTime: savedProgress) + presentAppSheet(.resumeAction( + video, + resumeTime: savedProgress, + onContinue: { playVideoWithStartTime(savedProgress) }, + onStartOver: { playVideoWithStartTime(0) } + )) } } else { // No saved progress or video was completed - play from beginning diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index 56045d7b..07dcd267 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -123,22 +123,6 @@ struct YatteeApp: App { OpenLinkSheet(prefilledURL: url) .appEnvironment(appEnvironment) } - .sheet(item: Binding( - get: { - // Only host the sheet here when the player is not expanded. - // When expanded, ExpandedPlayerSheet hosts it so it appears above the player window. - guard !appEnvironment.navigationCoordinator.isPlayerExpanded else { return nil } - return appEnvironment.navigationCoordinator.descriptionLinkQueueSheetVideo - }, - set: { newValue in - if newValue == nil { - appEnvironment.navigationCoordinator.descriptionLinkQueueSheetVideo = nil - } - } - )) { video in - QueueActionSheet(video: video) - .appEnvironment(appEnvironment) - } #if !os(tvOS) .sheet(isPresented: $showingDeepLinkDownloadSheet) { if let video = deepLinkVideo { @@ -598,7 +582,8 @@ struct YatteeApp: App { instance: instance ) appEnvironment.toastManager.dismiss(id: toastID) - appEnvironment.navigationCoordinator.descriptionLinkQueueSheetVideo = video + // The top-most sheet host (the player when expanded) presents it + appEnvironment.navigationCoordinator.presenter.present(.queueAction(video)) } catch { appEnvironment.toastManager.dismiss(id: toastID) appEnvironment.navigationCoordinator.navigate(to: .video(.id(videoID)))