Replaces the chain of dialogs (each waited ~450 ms for the last to close) with one sheet: Plex | Stem tabs, inline new-playlist name field, playlist rows with art, and mix chips for stem playlists. One "Add to Playlist…" menu entry now covers both kinds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
7.1 KiB
Swift
175 lines
7.1 KiB
Swift
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<Void, Never>?
|
|
|
|
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<T>(_ kp: ReferenceWritableKeyPath<Overlays, T?>) -> Binding<Bool> {
|
|
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())
|
|
}
|
|
}
|
|
}
|