Files
yattee/Yattee/Views/Components/LinkActionConfirmationSheet.swift
Arkadiusz Fal 5ae07574cb Add URL scheme actions: play, queue, download, bookmark, subscribe, player controls
yattee://play|play-next|queue|download|bookmark|subscribe|unsubscribe?url={link}
and yattee://player/play|pause|toggle|next|previous|close|seek|rate run through
one LinkActionPerformer that reports a LinkActionResult, ready for
x-callback-url. An unencoded url= keeps its own query; only the action's own
parameters end it.

Actions that change saved state ask first through a confirmation sheet
presented by the top-most sheet host, unless the new 'Run Link Actions
Without Asking' setting is on. Download skips the question when the quality
picker will show anyway. Player commands leave sheets and the player alone.
2026-10-05 23:58:43 +02:00

142 lines
4.2 KiB
Swift

//
// LinkActionConfirmationSheet.swift
// Yattee
//
// Asks before a URL scheme action changes saved state. Any web page can open
// a yattee:// link, so queueing, bookmarking, downloading and subscribing
// wait for the user unless Settings allows them to run on their own.
//
import SwiftUI
/// A pending question for `LinkActionConfirmationSheet`. Answered exactly once:
/// by a button, or as "cancel" when the sheet goes away any other way.
@MainActor
final class LinkActionConfirmation {
let id = UUID()
let title: String
let subject: String?
let detail: String?
let confirmTitle: String
let systemImage: String
private var continuation: CheckedContinuation<Bool, Never>?
init(title: String, subject: String?, detail: String?, confirmTitle: String, systemImage: String) {
self.title = title
self.subject = subject
self.detail = detail
self.confirmTitle = confirmTitle
self.systemImage = systemImage
}
/// Presents the sheet through the top-most host and waits for the answer.
func ask(using presenter: PresentationRouter) async -> Bool {
await withCheckedContinuation { continuation in
self.continuation = continuation
presenter.present(.linkActionConfirmation(self))
}
}
func answer(_ allowed: Bool) {
continuation?.resume(returning: allowed)
continuation = nil
}
}
struct LinkActionConfirmationSheet: View {
@Environment(\.dismiss) private var dismiss
let confirmation: LinkActionConfirmation
var body: some View {
content
.onDisappear {
// Swiped away or replaced by another sheet: treat as cancel
confirmation.answer(false)
}
}
#if os(tvOS)
private var content: some View {
NavigationStack {
List {
Section {
header
.padding(.vertical, 8)
}
Section {
Button { respond(true) } label: {
Label(confirmation.confirmTitle, systemImage: confirmation.systemImage)
}
Button(role: .cancel) { respond(false) } label: {
Label(String(localized: "common.cancel"), systemImage: "xmark")
}
}
}
.scrollClipDisabled()
.padding(.horizontal, 40)
.padding(.vertical, 24)
}
}
#else
private var content: some View {
VStack(spacing: 0) {
header
.padding(.horizontal, 20)
.padding(.top, 24)
.padding(.bottom, 20)
Divider()
HStack(spacing: 12) {
Button(role: .cancel) { respond(false) } label: {
Text(String(localized: "common.cancel"))
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button { respond(true) } label: {
Label(confirmation.confirmTitle, systemImage: confirmation.systemImage)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
.controlSize(.large)
.padding(20)
Spacer(minLength: 0)
}
#if os(macOS)
.frame(width: 380)
#endif
.presentationDetents([.height(210)])
.presentationDragIndicator(.visible)
}
#endif
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
Text(confirmation.title)
.font(.headline)
if let subject = confirmation.subject {
Text(subject)
.font(.subheadline)
.lineLimit(2)
}
if let detail = confirmation.detail {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func respond(_ allowed: Bool) {
confirmation.answer(allowed)
dismiss()
}
}