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

+510
View File
@@ -0,0 +1,510 @@
import SwiftUI
// ---- library home ---------------------------------------------------------------------------
struct LibraryHome: View {
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
@State private var albums: LoadState<[Album]> = .loading
@State private var reload = 0
private var g: AppGraph { .shared }
var body: some View {
let s = g.settings
let ready = !s.plexToken.isEmpty && !s.musicSection.isEmpty
let today = Date()
Page(title: "Library") {
if !ready {
EmptyState(icon: "server.rack", title: "Connect to Plex",
message: "Sign in to your Plex server to browse your music. Stems come from stemserve on the same box.", action: "Connect") {
ui.push(.PlexAccount)
}
}
let rows: [(String, String, Route)] = [
("music.note.list", "Playlists", .Playlists), ("music.mic", "Artists", .Artists),
("square.stack", "Albums", .Albums), ("music.note", "Songs", .Songs),
]
ForEach(Array(rows.enumerated()), id: \.offset) { i, r in
LibraryRow(icon: r.0, title: r.1, last: i == rows.count - 1) { ui.push(r.2) }
}
if ready {
SectionTitle(title: "On This Day")
Text(today.formatted(.dateTime.month(.wide).day())).font(.subheadline).foregroundStyle(p.label2)
.padding(.leading, MARGIN).padding(.bottom, 10)
Loaded(state: albums, retry: { reload += 1 }) { list in
let (onDay, week) = birthdays(list, today)
if !onDay.isEmpty {
AlbumGrid(albums: onDay.map(\.album), subtitle: { a in
onDay.first { $0.album.ratingKey == a.ratingKey }.map { "\(a.artist) · \(yearsAgo($0.years))" }
}, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) })
} else {
Text(week.isEmpty ? "No albums in your library came out this week in any year." : "No album birthdays today. Coming up and just past:")
.font(.subheadline).foregroundStyle(p.label2).lineLimit(3)
.padding(.horizontal, MARGIN).padding(.bottom, 12)
if !week.isEmpty {
AlbumGrid(albums: week.map(\.album), subtitle: { a in
week.first { $0.album.ratingKey == a.ratingKey }.map { b in
let verb = b.date < Calendar.current.startOfDay(for: today) ? "Turned" : "Turns"
return "\(verb) \(b.years) · \(b.date.formatted(.dateTime.month(.abbreviated).day()))"
}
}, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) })
}
}
}
}
}
.task(id: "\(s.musicSection)|\(reload)") {
guard ready else { return }
await LoadState.load($albums) { try await g.plex.albums() }
}
}
}
/// An album's anniversary: its release day this year, and how old it is then.
private struct Birthday { let album: Album; let date: Date; let years: Int }
private func yearsAgo(_ n: Int) -> String { n == 1 ? "1 year ago" : "\(n) years ago" }
/// Albums released on today's date in earlier years, and, for slow days, the three days either side.
private func birthdays(_ albums: [Album], _ now: Date) -> ([Birthday], [Birthday]) {
let cal = Calendar(identifier: .gregorian)
let today = cal.startOfDay(for: now)
let year = cal.component(.year, from: today)
let all: [Birthday] = albums.compactMap { a in
guard let r = a.released, r.count >= 10 else { return nil }
let parts = r.prefix(10).split(separator: "-").compactMap { Int($0) }
guard parts.count == 3, parts[0] < year else { return nil }
var day = parts[2]
// Feb 29 falls back to Feb 28 in other years.
if parts[1] == 2, day == 29, cal.range(of: .day, in: .month, for: cal.date(from: DateComponents(year: year, month: 2))!)!.count < 29 { day = 28 }
guard let d = cal.date(from: DateComponents(year: year, month: parts[1], day: day)) else { return nil }
return Birthday(album: a, date: d, years: year - parts[0])
}
let onDay = all.filter { $0.date == today }.sorted { $0.years > $1.years }
let week = all.filter { b in
b.date != today && abs(cal.dateComponents([.day], from: today, to: b.date).day ?? 99) <= 3
}.sorted { ($0.date, -$0.years) < ($1.date, -$1.years) }
return (onDay, week)
}
private struct LibraryRow: View {
let icon: String
let title: String
let last: Bool
let action: () -> Void
@Environment(\.palette) private var p
var body: some View {
Button(action: action) {
HStack(spacing: 14) {
Image(systemName: icon).font(.system(size: 22)).foregroundStyle(p.accent).frame(width: 26)
Text(title).font(.title3).foregroundStyle(p.label)
Spacer()
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
}
.frame(height: 52)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.listRow(inset: 60, trailing: 16, line: !last)
}
}
// ---- artists --------------------------------------------------------------------------------
struct ArtistsScreen: View {
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
@State private var artists: LoadState<[Artist]> = .loading
@State private var reload = 0
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
Loaded(state: artists, retry: { reload += 1 }) { list in
ForEach(list) { a in
Button { ui.push(.ArtistPage(a)) } label: { ArtistRow(artist: a).contentShape(Rectangle()) }
.buttonStyle(.plain)
.actionsMenu(ui.menu.artist(a))
.listRow(inset: MARGIN + 58, trailing: 28)
.id(a.ratingKey)
}
}
}
.padding(.bottom, 24)
}
.background(p.bg)
.overlay(alignment: .trailing) {
if let list = artists.value, list.count > 30 {
SectionIndex(titles: list.map(\.sortTitle)) { i in proxy.scrollTo(list[i].ratingKey, anchor: .top) }
}
}
}
.navigationTitle("Artists")
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
await LoadState.load($artists) { try await AppGraph.shared.plex.artists() }
}
}
}
private func letterOf(_ s: String) -> Character {
var t = s.drop { $0.isWhitespace }
if t.hasPrefix("The ") { t = t.dropFirst(4) }
guard let ch = t.first?.uppercased().first, ("A"..."Z").contains(ch) else { return "#" }
return ch
}
/// UITableView's section index: drag down the letters to jump.
private struct SectionIndex: View {
let titles: [String]
let jump: (Int) -> Void
@Environment(\.palette) private var p
@State private var last: Character?
private static let letters: [Character] = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ#")
var body: some View {
let first: [Character: Int] = titles.enumerated().reduce(into: [:]) { m, e in
let l = letterOf(e.element)
if m[l] == nil { m[l] = e.offset }
}
GeometryReader { outer in
VStack(spacing: 0) {
ForEach(Self.letters, id: \.self) { l in
Text(String(l)).font(.caption2.weight(.semibold)).foregroundStyle(p.accent).frame(maxHeight: .infinity)
}
}
.frame(width: 22, height: outer.size.height * 0.62)
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0)
.onChanged { v in
let h = outer.size.height * 0.62
let i = min(max(Int(v.location.y / h * CGFloat(Self.letters.count)), 0), Self.letters.count - 1)
let l = Self.letters[i]
if l == last { return }
last = l
if let target = Self.letters[i...].lazy.compactMap({ first[$0] }).first { jump(target) }
}
.onEnded { _ in last = nil })
.frame(maxHeight: .infinity)
.padding(.trailing, 2)
}
.frame(width: 24)
.sensoryFeedback(.selection, trigger: last) { _, new in new != nil }
}
}
// ---- albums ---------------------------------------------------------------------------------
private let ALBUM_SORTS = [("Title", "titleSort"), ("Artist", "artist.titleSort,year"), ("Recently Added", "addedAt:desc"), ("Year", "year:desc")]
struct AlbumsScreen: View {
@Environment(Ui.self) private var ui
@State private var sort = 0
@State private var albums: LoadState<[Album]> = .loading
@State private var reload = 0
var body: some View {
Page(title: "Albums") {
Loaded(state: albums, retry: { reload += 1 }) { list in
AlbumGrid(albums: list, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Sort") {
ui.overlays.actions("Sort By", ALBUM_SORTS.enumerated().map { i, s in
SheetAction(i == sort ? "✓ \(s.0)" : s.0) { sort = i }
})
}
}
}
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort)|\(reload)") {
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: ALBUM_SORTS[sort].1) }
}
}
}
// ---- songs (paged) --------------------------------------------------------------------------
struct SongsScreen: View {
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
@State private var tracks: [Track] = []
@State private var total = -1
@State private var loading = false
@State private var error: String?
private var g: AppGraph { .shared }
var body: some View {
Page(title: "Songs") {
if !tracks.isEmpty {
if total > 0 {
Text(songs(total)).font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
}
PlayShuffle(play: { g.playback.playTracks(Array(tracks.prefix(1000))) },
shuffle: { g.playback.playTracks(Array(tracks.prefix(1000)), shuffle: true) })
.padding(.bottom, 12)
}
ForEach(Array(tracks.enumerated()), id: \.element.id) { i, t in
TrackRow(track: t, menu: ui.menu.track(t)) {
let from = max(i - 200, 0)
g.playback.playTracks(Array(tracks[from..<min(i + 800, tracks.count)]), start: i - from)
}
.listRow(inset: MARGIN + 60)
.onAppear { if i > tracks.count - 40 { loadMore() } }
}
if loading { LoadingRow() }
if let error { ErrorRow(message: error) { loadMore() } }
}
.task { if tracks.isEmpty { loadMore() } }
}
private func loadMore() {
if loading || (total >= 0 && tracks.count >= total) { return }
loading = true
Task {
do {
let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200)
tracks += page; total = n; error = nil
} catch {
self.error = error.localizedDescription
}
loading = false
}
}
}
// ---- album & artist pages -------------------------------------------------------------------
struct AlbumScreen: View {
let album: Album
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
@Environment(\.displayScale) private var scale
@State private var tracks: LoadState<[Track]> = .loading
@State private var detail: Album?
@State private var reload = 0
private var g: AppGraph { .shared }
var body: some View {
let a = detail ?? album
let list = tracks.value ?? []
let allDownloaded = !list.isEmpty && list.allSatisfy { g.downloads.records[$0.ratingKey]?.done == true }
Page(title: a.title, large: false) {
VStack(spacing: 0) {
Artwork(thumb: a.thumb, size: 264, corner: 10, placeholder: "square.stack")
Text(a.title).font(.title3.weight(.semibold)).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2)
.padding(.horizontal, 32).padding(.top, 16)
Button {
if let k = a.artistKey { ui.push(.ArtistPage(Artist(ratingKey: k, title: a.artist, sortTitle: a.artist))) }
} label: {
Text(a.artist).font(.title3).foregroundStyle(p.accent).multilineTextAlignment(.center)
}
.buttonStyle(.plain)
.padding(.horizontal, 32)
let meta = [a.genre?.uppercased(), a.year.map(String.init)].compactMap { $0 }.joined(separator: " · ")
if !meta.isEmpty { Text(meta).font(.caption.weight(.semibold)).foregroundStyle(p.label2).padding(.top, 4) }
PlayShuffle(play: { g.playback.playTracks(list) }, shuffle: { g.playback.playTracks(list, shuffle: true) })
.padding(.vertical, 16)
p.separator.frame(height: 1 / scale).padding(.leading, MARGIN)
}
.frame(maxWidth: .infinity)
.padding(.top, 8)
Loaded(state: tracks, retry: { reload += 1 }) { list in
let discs = Set(list.compactMap(\.disc)).count > 1
ForEach(Array(list.enumerated()), id: \.element.id) { i, t in
if discs, let d = t.disc, i == 0 || list[i - 1].disc != d {
Text("Disc \(d)").font(.headline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.top, 18).padding(.bottom, 6)
}
TrackRow(track: t, number: t.index ?? i + 1, showArt: false, subtitle: t.artist != a.artist ? t.artist : nil,
menu: ui.menu.track(t, allowGoTo: false)) { g.playback.playTracks(list, start: i) }
.listRow(inset: MARGIN + 32)
}
Text("\(songs(list.count)), \(formatMinutes(list.reduce(0) { $0 + $1.durationMs }))")
.font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.top, 14)
}
}
.toolbar {
if !list.isEmpty {
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
if allDownloaded {
ui.overlays.confirm("Remove Download?", "“\(a.title)” will no longer be available offline.", "Remove") { g.downloads.remove(list) }
} else {
g.downloads.download(list); ui.overlays.hud("Downloading", "arrow.down.circle")
}
} label: { Image(systemName: allDownloaded ? "checkmark.circle" : "arrow.down.circle") }
MoreMenu(items: ui.menu.album(a))
}
}
}
.task(id: reload) {
async let d = try? g.plex.album(album.ratingKey)
await LoadState.load($tracks) { try await g.plex.albumTracks(album.ratingKey) }
if let d = await d ?? nil { detail = d }
}
}
}
struct ArtistScreen: View {
let artist: Artist
@Environment(Ui.self) private var ui
@State private var albums: LoadState<[Album]> = .loading
@State private var reload = 0
private var g: AppGraph { .shared }
var body: some View {
Page(title: artist.title) {
PlayShuffle(play: { playAll(false) }, shuffle: { playAll(true) }).padding(.top, 6).padding(.bottom, 18)
Loaded(state: albums, retry: { reload += 1 }) { list in
AlbumGrid(albums: list, showArtist: false, menu: { ui.menu.album($0, allowGoTo: false) }, open: { ui.push(.AlbumPage($0)) })
}
}
.toolbar { ToolbarItem(placement: .topBarTrailing) { MoreMenu(items: ui.menu.artist(artist)) } }
.task(id: reload) {
await LoadState.load($albums) { try await g.plex.artistAlbums(artist.ratingKey).sorted { ($0.year ?? 0) > ($1.year ?? 0) } }
}
}
private func playAll(_ shuffle: Bool) {
Task {
do { g.playback.playTracks(try await g.plex.artistTracks(artist.ratingKey), shuffle: shuffle) }
catch { ui.overlays.hud("Couldn't load songs", "exclamationmark.triangle") }
}
}
}
// ---- downloads ------------------------------------------------------------------------------
private let DOWNLOAD_SORTS = ["Download Order", "Artist", "Release Date"]
/// Downloaded songs gathered back into their albums.
private struct DownloadedAlbum {
let album: Album
let records: [DownloadRecord]
let released: String?
var added: Int64 { records.map(\.added).max() ?? 0 }
var done: Int { records.filter(\.done).count }
var failed: Int { records.filter { $0.error != nil }.count }
var complete: Bool { done == records.count }
var tracks: [Track] {
records.map(\.track).sorted { ($0.disc ?? 0, $0.index ?? 0, $0.title.lowercased()) < ($1.disc ?? 0, $1.index ?? 0, $1.title.lowercased()) }
}
}
private func artistSortKey(_ s: String) -> String {
let t = s.trimmingCharacters(in: .whitespaces).lowercased()
return t.hasPrefix("the ") ? String(t.dropFirst(4)) : t
}
struct DownloadsScreen: View {
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
// Release days come from the album list (served from the HTTP cache when the server's away).
@State private var info: [String: Album] = [:]
private var g: AppGraph { .shared }
var body: some View {
let records = g.downloads.records
let sort = g.settings.downloadSort
let albums = group(records, sort)
let byKey = Dictionary(albums.map { ($0.album.ratingKey, $0) }, uniquingKeysWith: { a, _ in a })
let allDone = albums.flatMap { $0.tracks.filter { records[$0.ratingKey]?.done == true } }
let pinned = String(format: "%.1f", Double(g.cache.usage.pinnedBytes) / 1e9)
Page(title: "Downloads") {
if albums.isEmpty {
EmptyState(icon: "arrow.down.circle", title: "Nothing Downloaded",
message: "Hold any album, artist, playlist or song and choose Download to play it, stems and all, without your server.")
} else {
Text("\(albums.count) album\(albums.count == 1 ? "" : "s") · \(songs(records.count)) · \(pinned) GB · by \(DOWNLOAD_SORTS[sort].lowercased())")
.font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
PlayShuffle(play: { g.playback.playTracks(allDone) }, shuffle: { g.playback.playTracks(allDone, shuffle: true) })
.padding(.bottom, 14)
AlbumGrid(albums: albums.map(\.album), subtitle: { a in
guard let d = byKey[a.ratingKey] else { return a.artist }
if d.failed > 0 { return "\(a.artist) · \(d.failed) failed" }
if !d.complete { return "\(a.artist) · \(d.done) of \(d.records.count)" }
if sort == 2, let r = d.released { return "\(a.artist) · \(r.prefix(4))" }
return a.artist
}, menu: { a in
guard let d = byKey[a.ratingKey] else { return [] }
var extra: [SheetAction] = []
if d.failed > 0 { extra.append(SheetAction("Retry Failed Songs", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
if !d.complete {
extra.append(SheetAction("Cancel Download", icon: "xmark.circle", destructive: true) {
g.downloads.remove(d.records.filter { !$0.done }.map(\.track))
})
}
return a.ratingKey.hasPrefix("name:") ? ui.menu.tracks("\(a.title) — \(a.artist)", d.tracks, extra: extra) : ui.menu.album(a, extra: extra)
}, open: { a in
guard let d = byKey[a.ratingKey] else { return }
if a.ratingKey.hasPrefix("name:") { g.playback.playTracks(d.tracks) } else { ui.push(.AlbumPage(a)) }
}) { a in
if let d = byKey[a.ratingKey], !d.complete {
let inFlight = d.records.reduce(Float(0)) { $0 + (g.downloads.progress[$1.track.ratingKey] ?? 0) }
ZStack {
Circle().fill(Color.black.opacity(0.55))
if d.failed > 0 {
Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundStyle(.white)
} else {
ProgressRing(progress: (Float(d.done) + inFlight) / Float(d.records.count), size: 20, color: .white)
}
}
.frame(width: 30, height: 30)
}
}
}
}
.toolbar {
if !albums.isEmpty {
ToolbarItemGroup(placement: .topBarTrailing) {
Button("Sort") {
ui.overlays.actions("Sort By", DOWNLOAD_SORTS.enumerated().map { i, label in
SheetAction(i == sort ? "✓ \(label)" : label) { g.settings.downloadSort = i }
})
}
MoreMenu(items: moreItems(albums, pinned))
}
}
}
.task(id: g.settings.musicSection) {
guard g.plex.signedIn else { return }
if let list = try? await g.plex.albums() { info = Dictionary(list.map { ($0.ratingKey, $0) }, uniquingKeysWith: { a, _ in a }) }
}
}
private func moreItems(_ albums: [DownloadedAlbum], _ pinned: String) -> [SheetAction] {
var a: [SheetAction] = []
if albums.contains(where: { $0.failed > 0 }) { a.append(SheetAction("Retry Failed", icon: "arrow.clockwise") { g.downloads.retryFailed() }) }
a.append(SheetAction("Remove All Downloads", icon: "trash", destructive: true) {
ui.overlays.confirm("Remove All Downloads?", "Frees \(pinned) GB. Songs stay in your library.", "Remove All") {
g.downloads.remove(g.downloads.records.values.map(\.track))
}
})
return a
}
private func group(_ records: [String: DownloadRecord], _ sort: Int) -> [DownloadedAlbum] {
let grouped = Dictionary(grouping: records.values) { r in r.track.albumKey ?? "name:\(r.track.artist)|\(r.track.album)" }.map { key, rs in
let t = rs[0].track
let known = info[key]
let artists = Set(rs.map(\.track.artist))
let album = known ?? Album(ratingKey: key, title: t.album.isEmpty ? "Unknown Album" : t.album,
artist: artists.count > 1 ? "Various Artists" : t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb)
return DownloadedAlbum(album: album, records: rs, released: known?.released ?? (known?.year ?? t.year).map(String.init))
}
switch sort {
case 1:
return grouped.sorted {
(artistSortKey($0.album.artist), $0.released ?? "", $0.album.title.lowercased()) <
(artistSortKey($1.album.artist), $1.released ?? "", $1.album.title.lowercased())
}
case 2:
return grouped.sorted { a, b in
(a.released ?? "") != (b.released ?? "") ? (a.released ?? "") > (b.released ?? "") : a.album.title.lowercased() < b.album.title.lowercased()
}
default:
return grouped.sorted { $0.added > $1.added }
}
}
}