Files
yattee/Yattee/Services/Navigation/PresentationRouter.swift
Arkadiusz Fal bc6ad55f38 Ask link action confirmations in an alert on tvOS
A tvOS sheet fills most of the screen, far too much for a two-button
question. The shared sheet host now presents link action confirmations
as an alert there, answering cancel when it goes away without a choice.
2026-10-07 23:59:44 +02:00

276 lines
10 KiB
Swift

//
// PresentationRouter.swift
// Yattee
//
// Shared sheets, declared once per host instead of once per screen or row.
//
// Views present through `@Environment(\.presentAppSheet)`, which reaches the
// nearest `.appSheetHost()` above them: the app root, the expanded player,
// or a sheet that is itself presented by a host (each presented sheet hosts
// the next level). Code outside the view tree uses `PresentationRouter.request`,
// which the top-most host (the player when expanded, the root otherwise)
// picks up.
//
import SwiftUI
/// Sheets presented through `.appSheetHost()`.
enum AppSheet: Identifiable {
/// Queue of the current player. `halfHeight` opens at the medium detent.
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)
/// 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)
/// Asks before a URL scheme action changes saved state.
case linkActionConfirmation(LinkActionConfirmation)
#if !os(tvOS)
/// Download quality picker. Streams and captions already loaded by the
/// caller (the player) skip the sheet's own fetch.
case downloadOptions(Video, streams: [Stream] = [], captions: [Caption] = [], dislikeCount: Int? = nil)
#endif
var id: String {
switch self {
case .queue:
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, _, _, _):
return "resumeAction-\(video.id.id)"
case .linkActionConfirmation(let confirmation):
return "linkActionConfirmation-\(confirmation.id)"
#if !os(tvOS)
case .downloadOptions(let video, _, _, _):
return "downloadOptions-\(video.id.id)"
#endif
}
}
}
@Observable
@MainActor
final class PresentationRouter {
/// A sheet requested from outside the view tree. The top-most host moves
/// it into its own state and clears this.
var request: AppSheet?
func present(_ sheet: AppSheet) {
request = sheet
}
}
// MARK: - Environment
/// Presents an `AppSheet` from the nearest host.
struct PresentAppSheetAction {
fileprivate let handler: @MainActor (AppSheet) -> Void
@MainActor
func callAsFunction(_ sheet: AppSheet) {
handler(sheet)
}
}
extension EnvironmentValues {
/// Outside any host this falls back to the global request, which the
/// top-most host presents.
@Entry var presentAppSheet = PresentAppSheetAction { sheet in
AppEnvironment.shared.navigationCoordinator.presenter.present(sheet)
}
}
// MARK: - Toasts
/// The toast overlay level of a presented sheet.
struct SheetToastLevel {
let scope: ToastScope
let depth: Int
}
extension EnvironmentValues {
@Entry var sheetToastLevel = SheetToastLevel(scope: .main, depth: 0)
}
// MARK: - Host
private struct AppSheetHost: ViewModifier {
@Environment(\.appEnvironment) private var appEnvironment
/// Whether this host presents global requests (`PresentationRouter.request`).
let isTopLevel: Bool
/// Whether external navigation dismisses this host's sheet. Only the main
/// window's root host: the expanded player keeps its own sheets.
let dismissesOnExternalNavigation: Bool
/// The toast scope of the window this host is in; nil inherits it.
let toastScope: ToastScope?
@Environment(\.sheetToastLevel) private var parentToastLevel
@State private var sheet: AppSheet?
/// Where this host's sheets show toasts: the window's scope, one level
/// deeper than the content they cover.
private var sheetToastLevel: SheetToastLevel {
SheetToastLevel(scope: toastScope ?? parentToastLevel.scope, depth: toastScope == nil ? parentToastLevel.depth + 1 : 1)
}
func body(content: Content) -> some View {
content
.environment(\.presentAppSheet, PresentAppSheetAction { sheet = $0 })
.sheet(item: presentedSheet) { sheet in
sheetContent(for: sheet)
.sheetToastOverlay(scope: sheetToastLevel.scope, depth: sheetToastLevel.depth)
.environment(\.sheetToastLevel, sheetToastLevel)
}
#if os(tvOS)
.linkActionConfirmationAlert(pendingConfirmation) { sheet = nil }
#endif
// Declared before the request observer so a deep link that both
// dismisses and presents ends with its own sheet up.
.onChange(of: dismissGeneration) { _, _ in
sheet = nil
}
.onChange(of: pendingRequestID, initial: true) { _, _ in
takePendingRequest()
}
}
/// The sheet to present as a sheet. On tvOS a link action confirmation
/// is a compact alert instead: a tvOS sheet fills most of the screen.
private var presentedSheet: Binding<AppSheet?> {
#if os(tvOS)
Binding {
if case .linkActionConfirmation = sheet { return nil }
return sheet
} set: { sheet = $0 }
#else
$sheet
#endif
}
#if os(tvOS)
private var pendingConfirmation: LinkActionConfirmation? {
if case .linkActionConfirmation(let confirmation) = sheet { return confirmation }
return nil
}
#endif
private var dismissGeneration: Int? {
dismissesOnExternalNavigation ? appEnvironment?.navigationCoordinator.dismissPresentationsGeneration : nil
}
private var pendingRequestID: String? {
isTopLevel ? appEnvironment?.navigationCoordinator.presenter.request?.id : nil
}
private func takePendingRequest() {
guard isTopLevel, let presenter = appEnvironment?.navigationCoordinator.presenter,
let request = presenter.request else { return }
presenter.request = nil
sheet = request
}
@ViewBuilder
private func sheetContent(for sheet: AppSheet) -> some View {
Group {
switch sheet {
case .queue(let halfHeight):
QueueManagementSheet()
.halfHeightDetents(halfHeight)
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):
ResumeActionSheet(video: video, resumeTime: resumeTime, onContinue: onContinue, onStartOver: onStartOver)
case .linkActionConfirmation(let confirmation):
LinkActionConfirmationSheet(confirmation: confirmation)
#if !os(tvOS)
case .downloadOptions(let video, let streams, let captions, let dislikeCount):
DownloadQualitySheet(video: video, streams: streams, captions: captions, dislikeCount: dislikeCount)
#endif
}
}
// The next level: sheets opened from inside this one. It goes away
// with this sheet, so it needn't observe external navigation itself.
.appSheetHost(isTopLevel: false)
.optionalAppEnvironment(appEnvironment)
}
}
private extension View {
@ViewBuilder
func halfHeightDetents(_ halfHeight: Bool) -> some View {
if halfHeight {
presentationDetents([.medium, .large])
} else {
self
}
}
@ViewBuilder
func optionalAppEnvironment(_ appEnvironment: AppEnvironment?) -> some View {
if let appEnvironment {
self.appEnvironment(appEnvironment)
} else {
self
}
}
}
extension View {
/// Hosts `AppSheet`s for the content below it.
///
/// - Parameter isTopLevel: true for the hosts that present global requests:
/// the app root while the player is collapsed, the expanded player while
/// it is up.
/// - Parameter dismissesOnExternalNavigation: true for the main window's
/// root host, whose sheet external navigation (notification, deep link,
/// Handoff) dismisses.
/// - Parameter toastScope: the toast scope of the window the host is in;
/// nil for hosts inside sheets, which inherit it.
func appSheetHost(isTopLevel: Bool, dismissesOnExternalNavigation: Bool = false, toastScope: ToastScope? = nil) -> some View {
modifier(AppSheetHost(
isTopLevel: isTopLevel,
dismissesOnExternalNavigation: dismissesOnExternalNavigation,
toastScope: toastScope
))
}
/// Dismisses a screen's own sheet when navigation arrives from outside the
/// app, so it doesn't stay up over the screen the navigation moved to.
func dismissesOnExternalNavigation(_ isPresented: Binding<Bool>) -> some View {
modifier(ExternalNavigationDismissal(isPresented: isPresented))
}
}
private struct ExternalNavigationDismissal: ViewModifier {
@Environment(\.appEnvironment) private var appEnvironment
@Binding var isPresented: Bool
func body(content: Content) -> some View {
content
.onChange(of: appEnvironment?.navigationCoordinator.dismissPresentationsGeneration) { _, _ in
isPresented = false
}
}
}