Present shared sheets through one host per window

The add-to-playlist, download options and queue sheets were declared in
every view that could open them: the root view (twice, mirrored from
coordinator flags), the expanded player, its portrait and floating
panels, the player controls and the video info view.

AppSheet lists the shared sheets, and .appSheetHost() declares them
once. The app root and the expanded player each host them, and every
presented sheet hosts the next level. Views present through the
presentAppSheet environment action, which reaches the nearest host;
code without a view (and the expanded player itself, whose host sits
in its own body) uses PresentationRouter.request, taken by the
top-most host: the player while it is up, the root otherwise.

The mini player's queue and playlist sheets now go through the host,
replacing the isMiniPlayer*SheetPresented flags.
This commit is contained in:
Arkadiusz Fal
2026-10-03 23:45:08 +02:00
parent c0495f3174
commit 28a75792e8
13 changed files with 241 additions and 151 deletions

View File

@@ -11,6 +11,7 @@ import SwiftUI
struct PlayerPillButtonView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.presentAppSheet) private var presentAppSheet
let configuration: ControlButtonConfiguration
let isPlaying: Bool
@@ -24,7 +25,6 @@ struct PlayerPillButtonView: View {
// Context for special buttons
let video: Video?
let playbackRate: PlaybackRate
@Binding var showingPlaylistSheet: Bool
// Callbacks
let onAction: () -> Void
@@ -263,7 +263,9 @@ struct PlayerPillButtonView: View {
private var addToPlaylistButton: some View {
Button {
showingPlaylistSheet = true
if let video {
presentAppSheet(.addToPlaylist(video))
}
} label: {
genericButtonLabel
}
@@ -326,7 +328,6 @@ struct PlayerPillButtonView: View {
isOrientationLocked: false,
video: nil,
playbackRate: .x1,
showingPlaylistSheet: .constant(false),
onAction: {},
onRateChanged: { _ in },
onTogglePiP: {}
@@ -348,7 +349,6 @@ struct PlayerPillButtonView: View {
isOrientationLocked: false,
video: nil,
playbackRate: .x1,
showingPlaylistSheet: .constant(false),
onAction: {},
onRateChanged: { _ in },
onTogglePiP: {}
@@ -364,7 +364,6 @@ struct PlayerPillButtonView: View {
isOrientationLocked: false,
video: nil,
playbackRate: .x1,
showingPlaylistSheet: .constant(false),
onAction: {},
onRateChanged: { _ in },
onTogglePiP: {}

View File

@@ -31,7 +31,6 @@ struct PlayerPillView: View {
// Video context for special buttons
let video: Video?
let playbackRate: PlaybackRate
@Binding var showingPlaylistSheet: Bool
// Action callbacks
let onQueueTap: () -> Void
@@ -62,7 +61,6 @@ struct PlayerPillView: View {
isOrientationLocked: Bool = false,
video: Video? = nil,
playbackRate: PlaybackRate = .x1,
showingPlaylistSheet: Binding<Bool> = .constant(false),
onQueueTap: @escaping () -> Void = {},
onPrevious: @escaping () -> Void = {},
onPlayPause: @escaping () -> Void = {},
@@ -86,7 +84,6 @@ struct PlayerPillView: View {
self.isOrientationLocked = isOrientationLocked
self.video = video
self.playbackRate = playbackRate
self._showingPlaylistSheet = showingPlaylistSheet
self.onQueueTap = onQueueTap
self.onPrevious = onPrevious
self.onPlayPause = onPlayPause
@@ -142,7 +139,6 @@ struct PlayerPillView: View {
isOrientationLocked: isOrientationLocked,
video: video,
playbackRate: playbackRate,
showingPlaylistSheet: $showingPlaylistSheet,
onAction: { handleAction(for: config) },
onRateChanged: onRateChanged,
onTogglePiP: onPiP

View File

@@ -121,7 +121,7 @@ extension ExpandedPlayerSheet {
/// Starts a download either automatically or by showing the quality sheet.
private func startDownload(for video: Video) {
guard let appEnvironment else {
showingDownloadSheet = true
presentDownloadOptions()
return
}
@@ -175,7 +175,7 @@ extension ExpandedPlayerSheet {
}
}
} else {
showingDownloadSheet = true
presentDownloadOptions()
}
}
#else
@@ -984,7 +984,7 @@ extension ExpandedPlayerSheet {
await playerService.playPrevious()
},
onShowQueue: { [self] in
showingQueueSheet = true
presentSheet(.queue())
},
onVolumeChanged: { [weak appEnvironment] volume in
playerService.currentBackend?.volume = volume
@@ -1084,10 +1084,10 @@ extension ExpandedPlayerSheet {
showingQualitySheet = true
},
onShowQueue: { [self] in
showingQueueSheet = true
presentSheet(.queue())
},
onShowPlaylistSelector: { [self] in
showingPlaylistSheet = true
presentAddToPlaylist()
},
onRateChanged: { rate in
playerState.rate = rate
@@ -1268,7 +1268,7 @@ extension ExpandedPlayerSheet {
await playerService.playPrevious()
},
onShowQueue: { [self] in
showingQueueSheet = true
presentSheet(.queue())
},
forceInitialHidden: forceHidden,
videoAreaTop: videoAreaTop,
@@ -1373,10 +1373,10 @@ extension ExpandedPlayerSheet {
showingQualitySheet = true
},
onShowQueue: { [self] in
showingQueueSheet = true
presentSheet(.queue())
},
onShowPlaylistSelector: { [self] in
showingPlaylistSheet = true
presentAddToPlaylist()
},
onRateChanged: { rate in
playerState.rate = rate
@@ -1704,7 +1704,7 @@ extension ExpandedPlayerSheet {
await playerService.playPrevious()
},
onShowQueue: { [self] in
showingQueueSheet = true
presentSheet(.queue())
},
videoAreaTop: (availableHeight - videoHeight) / 2,
videoAreaHeight: videoHeight,
@@ -1855,10 +1855,10 @@ extension ExpandedPlayerSheet {
showingQualitySheet = true
},
onShowQueue: { [self] in
showingQueueSheet = true
presentSheet(.queue())
},
onShowPlaylistSelector: { [self] in
showingPlaylistSheet = true
presentAddToPlaylist()
},
onTitleTap: {
onTogglePanel()

View File

@@ -18,10 +18,7 @@ struct ExpandedPlayerSheet: View {
// MARK: - Sheet State
@State var showingQualitySheet = false
@State var showingPlaylistSheet = false
@State var showingDownloadSheet = false
@State var showingDeleteDownloadAlert = false
@State var showingQueueSheet = false
@State var showingErrorSheet = false
@State var isCommentsExpanded: Bool = false
@State var commentsDismissOffset: CGFloat = 0
@@ -178,14 +175,38 @@ struct ExpandedPlayerSheet: View {
)
}
// MARK: - Shared Sheets
/// Presents through the player's sheet host. The host sits inside this
/// view's body, so this view's own environment can't reach it; the global
/// request does, because the player host takes requests while it is up.
func presentSheet(_ sheet: AppSheet) {
appEnvironment?.navigationCoordinator.presenter.present(sheet)
}
func presentAddToPlaylist() {
guard let video = playerState?.currentVideo else { return }
presentSheet(.addToPlaylist(video))
}
#if !os(tvOS)
func presentDownloadOptions() {
guard let video = playerState?.currentVideo, let playerService = appEnvironment?.playerService else { return }
presentSheet(.downloadOptions(
video,
streams: playerService.availableStreams,
captions: playerService.availableCaptions,
dislikeCount: playerState?.dislikeCount
))
}
#endif
// MARK: - Body
var body: some View {
playerContentWithOverlays
.applyPlayerSheets(
showingQualitySheet: $showingQualitySheet,
showingPlaylistSheet: $showingPlaylistSheet,
showingDownloadSheet: $showingDownloadSheet,
showingDeleteDownloadAlert: $showingDeleteDownloadAlert,
onStreamSelected: { stream, audioStream in
switchToStream(stream, audioStream: audioStream)
@@ -469,9 +490,6 @@ struct ExpandedPlayerSheet: View {
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: commentsState == .loaded && !comments.isEmpty)
.sheet(isPresented: $showingQueueSheet) {
QueueManagementSheet()
}
.sheet(isPresented: $showingErrorSheet) {
ErrorDetailsSheet(errorMessage: playerState?.errorMessage ?? "Unknown error")
}
@@ -495,6 +513,9 @@ struct ExpandedPlayerSheet: View {
shouldHost: (appEnvironment?.navigationCoordinator.isPlayerExpanded == true),
appEnvironment: appEnvironment
)
// Shared sheets opened from the player, and global requests while it
// is up, so they appear above the player window.
.appSheetHost(isTopLevel: appEnvironment?.navigationCoordinator.isPlayerExpanded == true)
#if os(iOS)
.toolbar(.hidden, for: .navigationBar)
// Corner layout with panels visible keeps the system status bar and
@@ -516,15 +537,11 @@ private extension View {
@ViewBuilder
func applyPlayerSheets(
showingQualitySheet: Binding<Bool>,
showingPlaylistSheet: Binding<Bool>,
showingDownloadSheet: Binding<Bool>,
showingDeleteDownloadAlert: Binding<Bool>,
onStreamSelected: @escaping (Stream, Stream?) -> Void
) -> some View {
modifier(PlayerSheetsModifier(
showingQualitySheet: showingQualitySheet,
showingPlaylistSheet: showingPlaylistSheet,
showingDownloadSheet: showingDownloadSheet,
showingDeleteDownloadAlert: showingDeleteDownloadAlert,
onStreamSelected: onStreamSelected
))
@@ -535,8 +552,6 @@ private extension View {
private struct PlayerSheetsModifier: ViewModifier {
@Environment(\.appEnvironment) var appEnvironment
@Binding var showingQualitySheet: Bool
@Binding var showingPlaylistSheet: Bool
@Binding var showingDownloadSheet: Bool
@Binding var showingDeleteDownloadAlert: Bool
let onStreamSelected: (Stream, Stream?) -> Void
@@ -549,15 +564,7 @@ private struct PlayerSheetsModifier: ViewModifier {
.sheet(isPresented: $showingQualitySheet) {
qualitySelectorSheet
}
.sheet(isPresented: $showingPlaylistSheet) {
if let video = playerState?.currentVideo {
PlaylistSelectorSheet(video: video)
}
}
#if !os(tvOS)
.sheet(isPresented: $showingDownloadSheet) {
downloadSheet
}
.alert(
String(localized: "player.deleteDownload.title"),
isPresented: $showingDeleteDownloadAlert
@@ -648,19 +655,6 @@ private struct PlayerSheetsModifier: ViewModifier {
}
}
#if !os(tvOS)
@ViewBuilder
private var downloadSheet: some View {
if let video = playerState?.currentVideo, let playerService {
DownloadQualitySheet(
video: video,
streams: playerService.availableStreams,
captions: playerService.availableCaptions,
dislikeCount: playerState?.dislikeCount
)
}
}
#endif
}
// MARK: - Player Event Handlers Modifier

View File

@@ -40,8 +40,7 @@ struct FloatingDetailsPanel: View {
@State private var scrollOffset: CGFloat = 0
@State private var panelHeight: CGFloat = 0
@State private var scrollToTopTrigger: Bool = false
@State private var showQueueSheet: Bool = false
@State private var showPlaylistSheet: Bool = false
@Environment(\.presentAppSheet) private var presentAppSheet
@State private var isDragHandleActive: Bool = false
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
@@ -173,14 +172,6 @@ struct FloatingDetailsPanel: View {
// Re-clamp width when available width changes (e.g., rotation)
panelWidth = clampWidth(panelWidth)
}
.sheet(isPresented: $showQueueSheet) {
QueueManagementSheet()
}
.sheet(isPresented: $showPlaylistSheet) {
if let currentVideo = playerState?.currentVideo {
PlaylistSelectorSheet(video: currentVideo)
}
}
}
@ViewBuilder
@@ -379,8 +370,7 @@ struct FloatingDetailsPanel: View {
}(),
video: video,
playbackRate: playerState?.rate ?? .x1,
showingPlaylistSheet: $showPlaylistSheet,
onQueueTap: { showQueueSheet = true },
onQueueTap: { presentAppSheet(.queue()) },
onPrevious: { Task { await playerService?.playPrevious() } },
onPlayPause: { playerService?.togglePlayPause() },
onNext: { Task { await playerService?.playNext() } },

View File

@@ -11,6 +11,7 @@ import NukeUI
#if os(iOS) || os(macOS)
struct MiniPlayerView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.presentAppSheet) private var presentAppSheet
/// Whether this is displayed as a tab bottom accessory (affects styling)
var isTabAccessory: Bool = false
@@ -509,7 +510,7 @@ struct MiniPlayerView: View {
private func queueButton(config: ControlButtonConfiguration) -> some View {
Button {
incrementTapCount(for: config)
navigationCoordinator?.isMiniPlayerQueueSheetPresented = true
presentAppSheet(.queue(halfHeight: true))
} label: {
ZStack(alignment: .bottom) {
Image(systemName: "list.bullet")
@@ -550,7 +551,9 @@ struct MiniPlayerView: View {
private func addToPlaylistButton(config: ControlButtonConfiguration) -> some View {
Button {
incrementTapCount(for: config)
navigationCoordinator?.isMiniPlayerPlaylistSheetPresented = true
if let video = appEnvironment?.playerService.state.currentVideo {
presentAppSheet(.addToPlaylist(video, halfHeight: true))
}
} label: {
Image(systemName: "text.badge.plus")
.font(isTabAccessory ? .title3 : .title2)

View File

@@ -120,7 +120,6 @@ struct PlayerControlsView: View {
// MARK: - Sheet State
@State private var showingPlaylistSheet = false
@State private var showingCaptionsSheet = false
@State private var showingVideoTrackSheet = false
@State private var showingAudioTrackSheet = false
@@ -305,11 +304,6 @@ struct PlayerControlsView: View {
.onReceive(NotificationCenter.default.publisher(for: UIScreen.brightnessDidChangeNotification)) { _ in
currentBrightness = UIScreen.main.brightness
}
.sheet(isPresented: $showingPlaylistSheet) {
if let video = currentVideo {
PlaylistSelectorSheet(video: video)
}
}
.sheet(isPresented: $showingCaptionsSheet) {
captionsSheet
}
@@ -491,7 +485,12 @@ struct PlayerControlsView: View {
},
onRateChanged: onRateChanged,
onCaptionSelected: onCaptionSelected,
onShowPlaylistSelector: { [self] in showingPlaylistSheet = true },
onShowPlaylistSelector: { [self] in
// Through the player's sheet host, which takes requests while the player is up
if let video = currentVideo {
appEnvironment?.navigationCoordinator.presenter.present(.addToPlaylist(video))
}
},
onShowQueue: onShowQueue,
onShowCaptionsSelector: { [self] in showingCaptionsSheet = true },
onShowChaptersSelector: { [self] in showingChaptersSheet = true },

View File

@@ -42,8 +42,7 @@ struct PortraitDetailsPanel: View {
@State private var selectedPage: PanelPageType = .info
@State private var scrollOffset: CGFloat = 0
@State private var scrollToTopTrigger: Bool = false
@State private var showQueueSheet: Bool = false
@State private var showPlaylistSheet: Bool = false
@Environment(\.presentAppSheet) private var presentAppSheet
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
private var accentColor: Color { settingsManager?.resolvedAccentColor ?? .accentColor }
@@ -261,14 +260,6 @@ struct PortraitDetailsPanel: View {
.onChange(of: isCommentsExpanded) { _, newValue in
appEnvironment?.navigationCoordinator.isCommentsExpanded = newValue
}
.sheet(isPresented: $showQueueSheet) {
QueueManagementSheet()
}
.sheet(isPresented: $showPlaylistSheet) {
if let currentVideo = playerState?.currentVideo {
PlaylistSelectorSheet(video: currentVideo)
}
}
}
}
@@ -431,8 +422,7 @@ struct PortraitDetailsPanel: View {
isOrientationLocked: appEnvironment?.settingsManager.inAppOrientationLock ?? false,
video: video,
playbackRate: playerState?.rate ?? .x1,
showingPlaylistSheet: $showPlaylistSheet,
onQueueTap: { showQueueSheet = true },
onQueueTap: { presentAppSheet(.queue()) },
onPrevious: { Task { await playerService?.playPrevious() } },
onPlayPause: { playerService?.togglePlayPause() },
onNext: { Task { await playerService?.playNext() } },

View File

@@ -166,7 +166,6 @@ struct QueueManagementSheet: View {
tvOSQueueListView
#else
QueueListView(onPlay: {
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
performDismiss()
})
#endif
@@ -177,7 +176,6 @@ struct QueueManagementSheet: View {
TVQueueListView(
autoFocusOnAppear: true,
onDidSelectVideo: {
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
performDismiss()
}
)

View File

@@ -19,6 +19,7 @@ enum VideoInfoInitMode: Sendable {
struct VideoInfoView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.presentAppSheet) private var presentAppSheet
@Environment(\.videoQueueContext) private var videoQueueContext
private let initMode: VideoInfoInitMode
@@ -45,7 +46,6 @@ struct VideoInfoView: View {
@State private var isBookmarked = false
@State private var showingErrorDetailsSheet = false
@State private var showingPlaylistSheet = false
@State private var showingCommentsSheet = false
@State private var showingRemoveBookmarkAlert = false
@State private var currentBookmark: Bookmark?
@@ -102,7 +102,6 @@ struct VideoInfoView: View {
// Download state
@State private var download: Download?
@State private var showingDownloadSheet = false
@State private var isEnqueuingDownload = false
@State private var showingRemoveDownloadConfirmation = false
@@ -394,8 +393,6 @@ struct VideoInfoView: View {
// Reset state when navigating to different video
showingCommentsSheet = false
showingPlaylistSheet = false
showingDownloadSheet = false
showingRemoveBookmarkAlert = false
bookmarkSaveTask?.cancel()
comments = []
@@ -441,11 +438,6 @@ struct VideoInfoView: View {
await loadVideoDetails()
}
}
.sheet(isPresented: $showingPlaylistSheet) {
if let video = displayedVideo {
PlaylistSelectorSheet(video: video)
}
}
#if !os(tvOS)
.sheet(isPresented: $showingErrorDetailsSheet) {
ErrorDetailsSheet(errorMessage: videoDetailsLoadError ?? initialVideoLoadError ?? "Unknown error")
@@ -469,11 +461,6 @@ struct VideoInfoView: View {
)
}
#if !os(tvOS)
.sheet(isPresented: $showingDownloadSheet) {
if let video = displayedVideo {
DownloadQualitySheet(video: video)
}
}
.onChange(of: downloadManager?.completedDownloads) { _, newValue in
// Update download state when completedDownloads changes
if let video = displayedVideo {
@@ -726,7 +713,7 @@ struct VideoInfoView: View {
.focused($isPlayFocused)
Button {
showingPlaylistSheet = true
presentAddToPlaylist()
} label: {
Label(String(localized: "video.context.addToPlaylist"), systemImage: "text.badge.plus")
.frame(maxWidth: .infinity, minHeight: 50)
@@ -1326,7 +1313,7 @@ struct VideoInfoView: View {
// Add to Playlist and Bookmark
HStack(spacing: 12) {
Button {
showingPlaylistSheet = true
presentAddToPlaylist()
} label: {
Label(String(localized: "video.context.addToPlaylist"), systemImage: "text.badge.plus")
.frame(maxWidth: .infinity)
@@ -1409,7 +1396,7 @@ struct VideoInfoView: View {
}
case .addToPlaylist:
Button {
showingPlaylistSheet = true
presentAddToPlaylist()
} label: {
Label(String(localized: "video.context.addToPlaylist"), systemImage: "text.badge.plus")
}
@@ -1565,7 +1552,7 @@ struct VideoInfoView: 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
}
@@ -1627,7 +1614,7 @@ struct VideoInfoView: View {
}
}
} else {
showingDownloadSheet = true
presentAppSheet(.downloadOptions(video))
}
}
@@ -2264,6 +2251,11 @@ struct VideoInfoView: View {
// MARK: - Actions
private func presentAddToPlaylist() {
guard let video = displayedVideo else { return }
presentAppSheet(.addToPlaylist(video))
}
private func toggleBookmark() {
guard let dataManager, let video = displayedVideo else { return }