import SwiftUI struct SheetAction: Identifiable { let id = UUID() var title: String var icon: String? var destructive = false var run: () -> Void init(_ title: String, icon: String? = nil, destructive: Bool = false, _ run: @escaping () -> Void) { self.title = title; self.icon = icon; self.destructive = destructive; self.run = run } } /// Action sheets, alerts, text prompts and the HUD, shown from anywhere. They're drawn by /// [OverlayHost]: one under the tabs and one inside the Now Playing sheet, and only the /// topmost of those shows them. @MainActor @Observable final class Overlays { struct Actions { var title: String; var items: [SheetAction] } struct Confirm { var title: String; var message: String?; var button: String; var destructive: Bool; var run: () -> Void } struct Prompt { var title: String; var message: String?; var initial: String; var button: String; var placeholder: String; var run: (String) -> Void } struct Hud: Equatable { let id = UUID(); var text: String; var icon: String } struct AddToPlaylist: Identifiable { let id = UUID(); var tracks: [Track]; var title: String; var skip: String? } var actions: Actions? var confirm: Confirm? var prompt: Prompt? var hud: Hud? var addToPlaylist: AddToPlaylist? /// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's. var level = 0 private var hudTask: Task? private var showing: Bool { actions != nil || confirm != nil || prompt != nil } /// Presents after the current dialog has gone, since SwiftUI drops one shown on top of another. private func present(_ f: @escaping () -> Void) { if showing { actions = nil; confirm = nil; prompt = nil Task { @MainActor in try? await Task.sleep(nanoseconds: 450_000_000) f() } } else { f() } } func actions(_ title: String, _ items: [SheetAction]) { present { self.actions = Actions(title: title, items: items) } } func confirm(_ title: String, _ message: String?, _ button: String, destructive: Bool = true, _ run: @escaping () -> Void) { present { self.confirm = Confirm(title: title, message: message, button: button, destructive: destructive, run: run) } } func prompt(_ title: String, _ message: String?, initial: String = "", _ button: String, placeholder: String = "", _ run: @escaping (String) -> Void) { present { self.prompt = Prompt(title: title, message: message, initial: initial, button: button, placeholder: placeholder, run: run) } } func addToPlaylist(_ tracks: [Track], title: String, skip: String?) { present { self.addToPlaylist = AddToPlaylist(tracks: tracks, title: title, skip: skip) } } func hud(_ text: String, _ icon: String = "checkmark") { hudTask?.cancel() withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) } let secs = text.count > 40 ? 2.8 : 1.4 hudTask = Task { @MainActor in try? await Task.sleep(nanoseconds: UInt64(secs * 1e9)) if Task.isCancelled { return } withAnimation(.easeIn(duration: 0.25)) { self.hud = nil } } } } struct OverlayHost: ViewModifier { let overlays: Overlays let level: Int @State private var text = "" @Environment(\.palette) private var p private var mine: Bool { overlays.level == level } func body(content: Content) -> some View { content .overlay { if mine, let h = overlays.hud { HudView(hud: h).transition(.opacity.combined(with: .scale(scale: 0.9))).allowsHitTesting(false) } } .confirmationDialog(overlays.actions?.title ?? "", isPresented: binding(\.actions), titleVisibility: .visible, presenting: overlays.actions) { a in ForEach(a.items) { item in Button(item.title, role: item.destructive ? .destructive : nil) { item.run() } } Button("Cancel", role: .cancel) {} } .alert(overlays.confirm?.title ?? "", isPresented: binding(\.confirm), presenting: overlays.confirm) { c in Button(c.button, role: c.destructive ? .destructive : nil) { c.run() } Button("Cancel", role: .cancel) {} } message: { c in if let m = c.message { Text(m) } } .alert(overlays.prompt?.title ?? "", isPresented: binding(\.prompt), presenting: overlays.prompt) { p in TextField(p.placeholder, text: $text).autocorrectionDisabled() Button(p.button) { let v = text.trimmingCharacters(in: .whitespacesAndNewlines) if !v.isEmpty { p.run(v) } } .disabled(text.trimmingCharacters(in: .whitespaces).isEmpty) Button("Cancel", role: .cancel) {} } message: { p in if let m = p.message { Text(m) } } .onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" } .sheet(item: Binding(get: { mine ? overlays.addToPlaylist : nil }, set: { overlays.addToPlaylist = $0 })) { r in AddToPlaylistSheet(req: r, overlays: overlays).environment(\.palette, p).tint(p.accent) } } private func binding(_ kp: ReferenceWritableKeyPath) -> Binding { Binding(get: { mine && overlays[keyPath: kp] != nil }, set: { if !$0 { overlays[keyPath: kp] = nil } }) } } private struct HudView: View { let hud: Overlays.Hud var body: some View { VStack(spacing: 10) { Image(systemName: hud.icon).font(.system(size: 40, weight: .medium)) Text(hud.text).font(.subheadline.weight(.semibold)).multilineTextAlignment(.center) } .padding(18) .frame(minWidth: 150, minHeight: 150) .frame(maxWidth: 240) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } } extension View { func overlayHost(_ o: Overlays, level: Int) -> some View { modifier(OverlayHost(overlays: o, level: level)) } /// The same actions on long-press as the ••• menu. func actionsMenu(_ items: [SheetAction]) -> some View { contextMenu { ActionButtons(items: items) } } } struct ActionButtons: View { let items: [SheetAction] var body: some View { ForEach(items) { item in Button(role: item.destructive ? .destructive : nil) { item.run() } label: { if let i = item.icon { Label(item.title, systemImage: i) } else { Text(item.title) } } } } } /// The ••• button, as a native menu. struct MoreMenu: View { let items: [SheetAction] var size: CGFloat = 17 @Environment(\.palette) private var p var body: some View { Menu { ActionButtons(items: items) } label: { Image(systemName: "ellipsis").font(.system(size: size, weight: .semibold)) .frame(width: 34, height: 34).contentShape(Rectangle()) } } }