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:
cdnutterandClaude Opus 5.5 committed 2026-10-20 07:15:47 -07:00
1 parent 45bc0eefe0
commit fdf9e970b2
45 files changed
+9822

No files matched your search

+332
View File
@@ -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) }
}
}
}
+181
View File
@@ -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) }
}
+164
View File
@@ -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())
}
}
}
+140
View File
@@ -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)
}
}