Add iOS app with CarPlay and Siri
SwiftUI port of the Android app under ios/, with CarPlay browse and Now Playing, Siri play-media intents, and a build-ipa.sh script that makes an unsigned IPA for sideloading. README gets an iOS section and the vibecoded disclaimer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45bc0eefe0
commit
fdf9e970b2
45 files changed
+9822
No files matched your search
@@ -0,0 +1,332 @@
|
||||
import SwiftUI
|
||||
|
||||
let MARGIN: CGFloat = 20
|
||||
|
||||
func formatDuration(_ ms: Int64) -> String {
|
||||
let s = max(ms / 1000, 0)
|
||||
return s >= 3600 ? String(format: "%d:%02d:%02d", s / 3600, s / 60 % 60, s % 60) : String(format: "%d:%02d", s / 60, s % 60)
|
||||
}
|
||||
|
||||
func formatMinutes(_ ms: Int64) -> String {
|
||||
let m = Int(ms / 60000)
|
||||
return m >= 60 ? "\(m / 60) hr \(m % 60) min" : "\(m) minute\(m == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
func songs(_ n: Int) -> String { "\(n) song\(n == 1 ? "" : "s")" }
|
||||
|
||||
func gb(_ bytes: Int64) -> String {
|
||||
let b = Double(bytes)
|
||||
return b < 1e9 ? String(format: "%.0f MB", b / 1e6) : String(format: "%.1f GB", b / 1e9)
|
||||
}
|
||||
|
||||
/// Plex artwork through the photo transcoder, sized to the screen's pixels.
|
||||
struct Artwork: View {
|
||||
let thumb: String?
|
||||
let size: CGFloat
|
||||
var corner: CGFloat = 6
|
||||
var circle = false
|
||||
var placeholder = "music.note"
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.displayScale) private var scale
|
||||
@State private var image: UIImage?
|
||||
|
||||
private var url: URL? {
|
||||
// Round the request up so nearby sizes share cache entries.
|
||||
let px = min(max(Int((size * scale / 120).rounded(.up)) * 120, 120), 1440)
|
||||
return AppGraph.shared.plex.artUrl(thumb, px: px)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let shape = circle ? AnyShape(Circle()) : AnyShape(RoundedRectangle(cornerRadius: corner, style: .continuous))
|
||||
ZStack {
|
||||
p.placeholder
|
||||
Image(systemName: placeholder).font(.system(size: size * 0.38)).foregroundStyle(p.label3)
|
||||
if let image {
|
||||
Image(uiImage: image).resizable().scaledToFill()
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(shape)
|
||||
.overlay(shape.stroke(p.separator.opacity(0.6), lineWidth: 0.5))
|
||||
.task(id: url) {
|
||||
guard let url else { image = nil; return }
|
||||
if let c = ArtLoader.shared.cached(url) { image = c; return }
|
||||
image = nil
|
||||
image = await ArtLoader.shared.image(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings.app-style coloured rounded-square icon.
|
||||
struct SettingsIcon: View {
|
||||
let symbol: String
|
||||
let bg: UInt32
|
||||
var body: some View {
|
||||
Image(systemName: symbol).font(.system(size: 16, weight: .medium)).foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Color(hex: bg), in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
/// An icon, title and grey value, as in Settings.app.
|
||||
struct ValueCell: View {
|
||||
let title: String
|
||||
var value: String?
|
||||
var icon: String?
|
||||
var iconBg: UInt32 = 0x8E8E93
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
if let icon { SettingsIcon(symbol: icon, bg: iconBg) }
|
||||
Text(title).foregroundStyle(p.label)
|
||||
Spacer(minLength: 8)
|
||||
if let value { Text(value).foregroundStyle(p.label2).lineLimit(1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyState: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
var action: String?
|
||||
var onAction: () -> Void = {}
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Image(systemName: icon).font(.system(size: 52)).foregroundStyle(p.label3)
|
||||
Text(title).font(.title3.weight(.semibold)).foregroundStyle(p.label).multilineTextAlignment(.center).padding(.top, 14)
|
||||
Text(message).font(.subheadline).foregroundStyle(p.label2).multilineTextAlignment(.center).padding(.top, 6)
|
||||
if let action {
|
||||
Button(action: onAction) {
|
||||
Text(action).font(.headline).foregroundStyle(.white).padding(.horizontal, 20).padding(.vertical, 10)
|
||||
.background(p.accent, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 40).padding(.vertical, 70)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct LoadingRow: View {
|
||||
var body: some View { ProgressView().controlSize(.large).frame(maxWidth: .infinity).padding(.vertical, 40) }
|
||||
}
|
||||
|
||||
struct ErrorRow: View {
|
||||
let message: String
|
||||
let retry: () -> Void
|
||||
@Environment(Ui.self) private var ui
|
||||
var body: some View {
|
||||
if !AppGraph.shared.plex.signedIn {
|
||||
EmptyState(icon: "server.rack", title: "Not Connected", message: "Sign in to your Plex server to see your music here.", action: "Connect") {
|
||||
ui.push(.PlexAccount)
|
||||
}
|
||||
} else {
|
||||
EmptyState(icon: "exclamationmark.triangle", title: "Couldn't Load", message: message, action: "Try Again", onAction: retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PillButton: View {
|
||||
let text: String
|
||||
let icon: String
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon).font(.system(size: 17, weight: .semibold))
|
||||
Text(text).font(.headline)
|
||||
}
|
||||
.foregroundStyle(p.accent)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(p.fill, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Play / Shuffle pair under a header.
|
||||
struct PlayShuffle: View {
|
||||
let play: () -> Void
|
||||
let shuffle: () -> Void
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
PillButton(text: "Play", icon: "play.fill", action: play)
|
||||
PillButton(text: "Shuffle", icon: "shuffle", action: shuffle)
|
||||
}
|
||||
.padding(.horizontal, MARGIN)
|
||||
}
|
||||
}
|
||||
|
||||
/// Capsule chip, accent-filled when selected.
|
||||
struct Chip: View {
|
||||
let text: String
|
||||
let selected: Bool
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text).font(.footnote.weight(.semibold)).foregroundStyle(selected ? .white : p.label)
|
||||
.padding(.horizontal, 14).frame(height: 32)
|
||||
.background(selected ? p.accent : p.fill, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Small outlined tag, e.g. "NO STEMS" next to a track.
|
||||
struct Tag: View {
|
||||
let text: String
|
||||
var color: Color?
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
let c = color ?? p.label2
|
||||
Text(text).font(.system(size: 10, weight: .bold)).foregroundStyle(c)
|
||||
.padding(.horizontal, 4).padding(.vertical, 1)
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).stroke(c, lineWidth: 1))
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloaded marker: filled circle with a down arrow, as in Apple Music's lists.
|
||||
struct DownloadedMark: View {
|
||||
var color: Color?
|
||||
var size: CGFloat = 14
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Image(systemName: "arrow.down.circle.fill").font(.system(size: size)).foregroundStyle(color ?? p.label2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Download progress ring.
|
||||
struct ProgressRing: View {
|
||||
let progress: Float
|
||||
var size: CGFloat = 18
|
||||
var color: Color?
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle().stroke(color.map { $0.opacity(0.3) } ?? p.fillStrong, lineWidth: 2)
|
||||
Circle().trim(from: 0, to: CGFloat(min(max(progress, 0), 1)))
|
||||
.stroke(color ?? p.accent, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
.frame(width: size - 2, height: size - 2)
|
||||
.animation(.linear(duration: 0.2), value: progress)
|
||||
}
|
||||
}
|
||||
|
||||
/// Animated "now playing" bars used in place of a track number.
|
||||
struct PlayingBars: View {
|
||||
let playing: Bool
|
||||
var color: Color?
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
TimelineView(.animation(paused: !playing)) { tl in
|
||||
let phase = tl.date.timeIntervalSinceReferenceDate * (2 * .pi / 1.1)
|
||||
Canvas { ctx, size in
|
||||
let w = size.width / 4.5
|
||||
for i in 0..<3 {
|
||||
let h = playing ? 0.35 + 0.65 * abs(sin(phase + Double(i) * 1.9)) : 0.3 + Double(i) * 0.15
|
||||
let bh = size.height * h
|
||||
let r = CGRect(x: Double(i) * w * 1.6 + w * 0.3, y: size.height - bh, width: w, height: bh)
|
||||
ctx.fill(Path(roundedRect: r, cornerRadius: w / 3), with: .color(color ?? p.accent))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 16, height: 16)
|
||||
}
|
||||
}
|
||||
|
||||
/// Section title with an optional accent action, as in Apple Music ("Recently Added").
|
||||
struct SectionTitle: View {
|
||||
let title: String
|
||||
var action: String?
|
||||
var onAction: () -> Void = {}
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(title).font(.title2.bold()).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
if let action { Button(action, action: onAction).foregroundStyle(p.accent) }
|
||||
}
|
||||
.padding(.leading, MARGIN).padding(.trailing, 12).padding(.top, 22).padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a screen's data is doing. A failed refresh keeps the data already on screen.
|
||||
enum LoadState<T> {
|
||||
case loading
|
||||
case ok(T)
|
||||
case err(String)
|
||||
|
||||
var value: T? { if case .ok(let v) = self { v } else { nil } }
|
||||
}
|
||||
|
||||
extension LoadState {
|
||||
/// Runs [f], keeping old data if it fails after a success.
|
||||
@MainActor
|
||||
static func load(_ state: Binding<LoadState<T>>, _ f: () async throws -> T) async {
|
||||
do {
|
||||
state.wrappedValue = .ok(try await f())
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
if state.wrappedValue.value == nil { state.wrappedValue = .err(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The loading spinner, error page or content for a [LoadState].
|
||||
struct Loaded<T, Content: View>: View {
|
||||
let state: LoadState<T>
|
||||
let retry: () -> Void
|
||||
@ViewBuilder let content: (T) -> Content
|
||||
var body: some View {
|
||||
switch state {
|
||||
case .loading: LoadingRow()
|
||||
case .err(let m): ErrorRow(message: m, retry: retry)
|
||||
case .ok(let v): content(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain scrolling page with a large title, like the Android shell's Page.
|
||||
struct Page<Content: View>: View {
|
||||
let title: String
|
||||
var large = true
|
||||
@ViewBuilder let content: () -> Content
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) { content() }
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.background(p.bg)
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(large ? .large : .inline)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Row padding plus the inset hairline under it.
|
||||
func listRow(inset: CGFloat = MARGIN, leading: CGFloat = MARGIN, trailing: CGFloat = 6, line: Bool = true) -> some View {
|
||||
modifier(ListRow(inset: inset, leading: leading, trailing: trailing, line: line))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ListRow: ViewModifier {
|
||||
let inset: CGFloat, leading: CGFloat, trailing: CGFloat, line: Bool
|
||||
@Environment(\.palette) private var p
|
||||
@Environment(\.displayScale) private var scale
|
||||
func body(content: Content) -> some View {
|
||||
content.padding(.leading, leading).padding(.trailing, trailing)
|
||||
.overlay(alignment: .bottom) {
|
||||
if line { p.separator.frame(height: 1 / scale).padding(.leading, inset) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The long-press / ••• menu for anything playable: songs, albums, artists and playlists all
|
||||
/// offer the front or end of the queue, download, and add-to-playlist (playlists included).
|
||||
/// Each call returns the actions; follow-up choices go through [Overlays].
|
||||
@MainActor
|
||||
struct MediaMenu {
|
||||
let overlays: Overlays
|
||||
let ui: Ui
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
func track(_ t: Track, extra: [SheetAction] = [], allowGoTo: Bool = true) -> [SheetAction] {
|
||||
var a = queueActions { [t] }
|
||||
if g.downloads.records[t.ratingKey] == nil {
|
||||
a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download([t]); overlays.hud("Downloading", "arrow.down.circle") })
|
||||
} else {
|
||||
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) { g.downloads.remove([t]) })
|
||||
}
|
||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { addToPlaylist([t]) })
|
||||
if t.partFile != nil, g.stems.index.size > 0, g.stems.find(t) == nil { a.append(stemNow { [t] }) }
|
||||
if allowGoTo, let k = t.albumKey {
|
||||
a.append(SheetAction("Go to Album", icon: "square.stack") {
|
||||
ui.showInLibrary(.AlbumPage(Album(ratingKey: k, title: t.album, artist: t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb)))
|
||||
})
|
||||
}
|
||||
if allowGoTo, let k = t.artistKey {
|
||||
a.append(SheetAction("Go to Artist", icon: "music.mic") { ui.showInLibrary(.ArtistPage(Artist(ratingKey: k, title: t.artist, sortTitle: t.artist))) })
|
||||
}
|
||||
return a + extra
|
||||
}
|
||||
|
||||
func album(_ al: Album, extra: [SheetAction] = [], allowGoTo: Bool = true) -> [SheetAction] {
|
||||
let have = g.downloads.records.values.filter { $0.done && $0.track.albumKey == al.ratingKey }.map(\.track)
|
||||
let cov = g.coverage.albums[al.ratingKey]
|
||||
let total = cov?.total ?? al.trackCount ?? Int.max
|
||||
var more: [SheetAction] = []
|
||||
if allowGoTo, let k = al.artistKey {
|
||||
more.append(SheetAction("Go to Artist", icon: "music.mic") { ui.showInLibrary(.ArtistPage(Artist(ratingKey: k, title: al.artist, sortTitle: al.artist))) })
|
||||
}
|
||||
let plex = g.plex
|
||||
return collection("\(al.title) — \(al.artist)", have, have.count >= total, { try await plex.albumTracks(al.ratingKey) },
|
||||
playlistKey: nil, extra: more + extra, stemmed: stemmed(cov))
|
||||
}
|
||||
|
||||
func artist(_ ar: Artist) -> [SheetAction] {
|
||||
let have = g.downloads.records.values.filter { $0.done && $0.track.artistKey == ar.ratingKey }.map(\.track)
|
||||
let cov = g.coverage.artists[ar.ratingKey]
|
||||
let plex = g.plex
|
||||
return collection(ar.title, have, have.count >= (cov?.total ?? Int.max), { try await plex.artistTracks(ar.ratingKey) },
|
||||
playlistKey: nil, extra: [], confirmDownload: true, stemmed: stemmed(cov))
|
||||
}
|
||||
|
||||
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
|
||||
let plex = g.plex
|
||||
return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: extra,
|
||||
confirmDownload: pl.trackCount > 50)
|
||||
}
|
||||
|
||||
/// For songs already in hand (downloads with no Plex album behind them).
|
||||
func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] {
|
||||
let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) }
|
||||
let stemmed = g.stems.index.size > 0 && tracks.allSatisfy { $0.partFile == nil || g.stems.find($0) != nil }
|
||||
return collection(title, have, have.count == tracks.count, { tracks }, playlistKey: nil, extra: extra, stemmed: stemmed)
|
||||
}
|
||||
|
||||
/// True when every song is known to have stems, so there's nothing for Stem Now to do.
|
||||
private func stemmed(_ cov: Coverage?) -> Bool {
|
||||
guard g.coverage.ready, let cov else { return false }
|
||||
return cov.withStems >= cov.total
|
||||
}
|
||||
|
||||
private func collection(_ title: String, _ downloaded: [Track], _ allDownloaded: Bool, _ load: @escaping () async throws -> [Track],
|
||||
playlistKey: String?, extra: [SheetAction], confirmDownload: Bool = false, stemmed: Bool = false) -> [SheetAction] {
|
||||
var a = queueActions(load)
|
||||
if !allDownloaded {
|
||||
a.append(SheetAction(downloaded.isEmpty ? "Download" : "Download the Rest", icon: "arrow.down.circle") {
|
||||
withTracks(load) { tracks in
|
||||
let go = { g.downloads.download(tracks); overlays.hud("Downloading", "arrow.down.circle") }
|
||||
if confirmDownload && tracks.count > 20 {
|
||||
overlays.confirm("Download \(songs(tracks.count))?", "“\(title)” will play without your server, stems and all.", "Download", destructive: false, go)
|
||||
} else {
|
||||
go()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if !downloaded.isEmpty {
|
||||
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) {
|
||||
overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(downloaded) }
|
||||
})
|
||||
}
|
||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey) } })
|
||||
if !stemmed { a.append(stemNow(load)) }
|
||||
return a + extra
|
||||
}
|
||||
|
||||
/// Front of the queue (right after this song) and the end of it.
|
||||
private func queueActions(_ load: @escaping () async throws -> [Track]) -> [SheetAction] {
|
||||
[
|
||||
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
|
||||
withTracks(load) { g.playback.playNext($0); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward") }
|
||||
},
|
||||
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
|
||||
withTracks(load) { g.playback.addToQueue($0); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward") }
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Has stemd on the server separate the songs that lack stems ahead of the rest of the library.
|
||||
private func stemNow(_ load: @escaping () async throws -> [Track]) -> SheetAction {
|
||||
SheetAction("Stem Now", icon: "waveform") {
|
||||
Task {
|
||||
do {
|
||||
let n = try await g.stems.stemNow(try await load())
|
||||
if n == 0 { overlays.hud("Already has stems", "info.circle") }
|
||||
else { overlays.hud(n == 1 ? "Stemming next" : "Stemming \(songs(n)) next", "waveform") }
|
||||
} catch {
|
||||
overlays.hud("Couldn't queue: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func withTracks(_ load: @escaping () async throws -> [Track], _ then: @escaping ([Track]) -> Void) {
|
||||
Task {
|
||||
do {
|
||||
let t = try await load()
|
||||
if t.isEmpty { overlays.hud("Nothing to add", "info.circle") } else { then(t) }
|
||||
} catch {
|
||||
overlays.hud("Couldn't load songs: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addToPlaylist(_ tracks: [Track], skip: String? = nil) {
|
||||
Task {
|
||||
let lists: [Playlist]
|
||||
do { lists = try await g.plex.playlists().filter { !$0.smart && $0.ratingKey != skip } } catch {
|
||||
overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
|
||||
return
|
||||
}
|
||||
let keys = tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") }
|
||||
if keys.isEmpty {
|
||||
overlays.hud("These songs aren't in Plex, so they can't go in a playlist", "info.circle")
|
||||
return
|
||||
}
|
||||
let plex = g.plex
|
||||
overlays.actions("Add \(songs(keys.count)) to Playlist", [SheetAction("New Playlist…") { newPlaylist(tracks) }] + lists.map { pl in
|
||||
SheetAction(pl.title) {
|
||||
Task {
|
||||
do {
|
||||
try await plex.addToPlaylist(pl.ratingKey, trackKeys: keys)
|
||||
overlays.hud("Added to \(pl.title)")
|
||||
} catch {
|
||||
overlays.hud("Couldn't add: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newPlaylist(_ tracks: [Track], onCreated: @escaping () -> Void = {}) {
|
||||
let plex = g.plex
|
||||
overlays.prompt("New Playlist", "Name it; you can add more songs later.", "Create", placeholder: "Playlist name") { name in
|
||||
Task {
|
||||
do {
|
||||
_ = try await plex.createPlaylist(name, trackKeys: tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") })
|
||||
overlays.hud("Created “\(name)”")
|
||||
onCreated()
|
||||
} catch {
|
||||
overlays.hud("Couldn't create: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Ui {
|
||||
var menu: MediaMenu { MediaMenu(overlays: overlays, ui: self) }
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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 }
|
||||
|
||||
var actions: Actions?
|
||||
var confirm: Confirm?
|
||||
var prompt: Prompt?
|
||||
var hud: Hud?
|
||||
/// 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 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 = ""
|
||||
|
||||
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 ?? "" }
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
/// "NO STEMS" / "3/10 STEMS" for an album or artist, once the whole library has been matched.
|
||||
struct StemFlag: View {
|
||||
let cov: Coverage?
|
||||
var body: some View {
|
||||
if AppGraph.shared.coverage.ready, let cov {
|
||||
if cov.none { Tag(text: "NO STEMS") } else if cov.partial { Tag(text: "\(cov.withStems)/\(cov.total) STEMS") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apple Music list row: art (or track number), title, artist, badges, and a ••• menu.
|
||||
/// Stems are the norm once the server has worked through the library, so the badge marks the
|
||||
/// songs without them, and only once the index has loaded.
|
||||
struct TrackRow<Leading: View>: View {
|
||||
let track: Track
|
||||
var number: Int?
|
||||
var showArt = true
|
||||
var subtitle: String?
|
||||
var menu: [SheetAction]?
|
||||
let action: () -> Void
|
||||
@ViewBuilder var leading: () -> Leading
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
var body: some View {
|
||||
let g = AppGraph.shared
|
||||
let pm = g.playback
|
||||
let isCurrent = pm.current?.track.ratingKey == track.ratingKey
|
||||
let noStems = g.stems.index.size > 0 && g.stems.find(track) == nil
|
||||
let downloaded = g.downloads.records[track.ratingKey]?.done == true
|
||||
let progress = g.downloads.progress[track.ratingKey]
|
||||
HStack(spacing: 0) {
|
||||
leading()
|
||||
if showArt {
|
||||
ZStack {
|
||||
Artwork(thumb: track.thumb, size: 48, corner: 5)
|
||||
if isCurrent {
|
||||
Color.black.opacity(0.35).frame(width: 48, height: 48).clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
PlayingBars(playing: pm.playWhenReady, color: .white)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 12)
|
||||
} else if let number {
|
||||
Group {
|
||||
if isCurrent { PlayingBars(playing: pm.playWhenReady) } else { Text("\(number)").foregroundStyle(p.label2) }
|
||||
}
|
||||
.frame(width: 32, alignment: .leading)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(track.title).foregroundStyle(isCurrent ? p.accent : p.label).lineLimit(1)
|
||||
if let s = subtitle ?? (showArt ? track.artist : nil) {
|
||||
Text(s).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
Spacer(minLength: 6)
|
||||
if noStems { Tag(text: "NO STEMS").padding(.trailing, 6) }
|
||||
if let progress { ProgressRing(progress: progress, size: 16).padding(.trailing, 4) }
|
||||
else if downloaded { DownloadedMark().padding(.trailing, 4) }
|
||||
if let menu { MoreMenu(items: menu).foregroundStyle(p.label) }
|
||||
}
|
||||
.frame(minHeight: showArt ? 56 : 40)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
.modifier(OptionalMenu(items: menu))
|
||||
}
|
||||
}
|
||||
|
||||
extension TrackRow where Leading == EmptyView {
|
||||
init(track: Track, number: Int? = nil, showArt: Bool = true, subtitle: String? = nil, menu: [SheetAction]? = nil, action: @escaping () -> Void) {
|
||||
self.init(track: track, number: number, showArt: showArt, subtitle: subtitle, menu: menu, action: action) { EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
struct OptionalMenu: ViewModifier {
|
||||
let items: [SheetAction]?
|
||||
func body(content: Content) -> some View {
|
||||
if let items { content.actionsMenu(items) } else { content }
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-column album grid. [subtitle] replaces the artist (or year) line; [badge] sits on the art's corner.
|
||||
struct AlbumGrid<Badge: View>: View {
|
||||
let albums: [Album]
|
||||
var showArtist = true
|
||||
var subtitle: ((Album) -> String?)?
|
||||
var menu: ((Album) -> [SheetAction])?
|
||||
let open: (Album) -> Void
|
||||
@ViewBuilder var badge: (Album) -> Badge
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
var body: some View {
|
||||
let cov = AppGraph.shared.coverage.albums
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 16, alignment: .top), GridItem(.flexible(), spacing: 16, alignment: .top)], spacing: 18) {
|
||||
ForEach(albums) { a in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
GeometryReader { geo in
|
||||
Artwork(thumb: a.thumb, size: geo.size.width, corner: 8, placeholder: "square.stack")
|
||||
.overlay(alignment: .bottomTrailing) { badge(a).padding(8) }
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
HStack(spacing: 6) {
|
||||
Text(a.title).font(.subheadline.weight(.medium)).foregroundStyle(p.label).lineLimit(1)
|
||||
StemFlag(cov: cov[a.ratingKey])
|
||||
}
|
||||
.padding(.top, 6)
|
||||
let sub = subtitle?(a) ?? (showArtist ? a.artist : a.year.map(String.init) ?? "")
|
||||
if !sub.isEmpty { Text(sub).font(.subheadline).foregroundStyle(p.label2).lineLimit(1) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { open(a) }
|
||||
.modifier(OptionalMenu(items: menu?(a)))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, MARGIN)
|
||||
}
|
||||
}
|
||||
|
||||
extension AlbumGrid where Badge == EmptyView {
|
||||
init(albums: [Album], showArtist: Bool = true, subtitle: ((Album) -> String?)? = nil, menu: ((Album) -> [SheetAction])? = nil, open: @escaping (Album) -> Void) {
|
||||
self.init(albums: albums, showArtist: showArtist, subtitle: subtitle, menu: menu, open: open) { _ in EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
struct ArtistRow: View {
|
||||
let artist: Artist
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Artwork(thumb: artist.thumb, size: 44, circle: true, placeholder: "person.fill")
|
||||
Text(artist.title).foregroundStyle(p.label).lineLimit(1)
|
||||
Spacer()
|
||||
StemFlag(cov: AppGraph.shared.coverage.artists[artist.ratingKey])
|
||||
}
|
||||
.frame(minHeight: 52)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user