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

+286
View File
@@ -0,0 +1,286 @@
import Foundation
/// What a pick or a voice request turns into: a queue, where to start, and whether to shuffle it.
struct Resolved {
var tracks: [Track]
var start = 0
var shuffle = false
}
/// A row in the car's lists; CarPlayScene turns these into CPListItems.
struct CarItem {
enum Art: Equatable {
case none
case thumb(String)
case letter(String)
case shuffle
}
var id: String
var title: String
var subtitle: String?
var art: Art = .none
var playable: Bool
/// Section header the row sits under, nil for the list's first unnamed section.
var group: String?
var downloaded = false
var trackKey: String?
}
/// The browse tree the car draws. CarPlay renders the lists itself, so designing for the car
/// means shaping this tree: four tabs, shallow folders, Shuffle as the first row, A–Z folders
/// instead of long lists.
///
/// Away from home (Plex and stemserve unreachable) everything comes from the HTTP cache and
/// only songs that will actually play offline are listed, with Downloads as the first tab.
final class CarLibrary: @unchecked Sendable {
private static let cacheNs: UInt64 = 120_000_000_000
private let plex: PlexClient
private let reach: HomeReach
private let downloads: Downloads
private let stems: StemServer
private let pm: PlaybackManager
private struct Cached { let at: UInt64; let tracks: [Track] }
private let lock = UnfairLock()
private var trackCache: [String: Cached] = [:]
private var order: [String] = []
init(plex: PlexClient, reach: HomeReach, downloads: Downloads, stems: StemServer, pm: PlaybackManager) {
self.plex = plex
self.reach = reach
self.downloads = downloads
self.stems = stems
self.pm = pm
}
private func away() async -> Bool { !(await reach.reachable()) }
// ---- tree -----------------------------------------------------------------------
/// Tab ids and titles, Downloads first when away from home.
func tabs() async -> [(String, String)] {
let tabs = [(CarIds.playlists, "Playlists"), (CarIds.albums, "Albums"), (CarIds.artists, "Artists"), (CarIds.downloads, "Downloads")]
return await away() ? [tabs[3]] + tabs.prefix(3) : tabs
}
/// Children of [id], or nil if the id means nothing to us.
func children(_ id: String) async throws -> [CarItem]? {
let away = await away()
switch id {
case CarIds.downloads: return await songs(CarIds.downloads, try tracksOf(CarIds.downloads), shuffleRow: true, showArtist: true)
case CarIds.playlists: return try await playlists(away)
case CarIds.albums: return try await albumsTab(away)
case CarIds.artists: return try await artistsTab(away)
default:
guard let (kind, key) = CarIds.split(id) else { return nil }
switch kind {
case "playlist": return await songs(id, try tracksOf(id), shuffleRow: true, showArtist: true)
case "album": return await songs(id, try tracksOf(id), shuffleRow: false, showArtist: false)
case "artist": return try await artistPage(key, away)
case "albums": return try await albumsFor(away).filter { CarTree.letterOf($0.title) == key }.map { albumItem($0) }
case "artists": return try await artistsFor(away).filter { CarTree.letterOf($0.sortTitle) == key }.map { artistItem($0) }
case "search": return try await searchItems(key)
default: return nil
}
}
}
private func playlists(_ away: Bool) async throws -> [CarItem] {
let all = try await plex.playlists()
var shown: [Playlist] = []
if away {
for p in all where ((try? await tracksOf(CarIds.playlist(p.ratingKey)))?.isEmpty == false) { shown.append(p) }
} else {
shown = all
}
return shown.map { playlistItem($0) }
}
private func albumsTab(_ away: Bool) async throws -> [CarItem] {
let albums = try await albumsFor(away)
if away || albums.count <= CarTree.flatMax { return albums.map { albumItem($0) } }
let recent = (try? await plex.recentlyAdded(count: CarTree.recentAlbums)) ?? []
return recent.map { albumItem($0, group: "Recently added") } +
CarTree.buckets(albums, \.title).map { letter, list in
CarItem(id: CarIds.albumLetter(letter), title: letter, subtitle: CarTree.countLabel(list.count, "album", "albums"),
art: .letter(letter), playable: false, group: "All albums")
}
}
private func artistsTab(_ away: Bool) async throws -> [CarItem] {
let artists = try await artistsFor(away)
if artists.count <= CarTree.flatMax { return artists.map { artistItem($0) } }
return CarTree.buckets(artists, \.sortTitle).map { letter, list in
CarItem(id: CarIds.artistLetter(letter), title: letter, subtitle: CarTree.countLabel(list.count, "artist", "artists"),
art: .letter(letter), playable: false)
}
}
private func artistPage(_ key: String, _ away: Bool) async throws -> [CarItem] {
let id = CarIds.artist(key)
let albums = away ? albumsOf(downloadedTracks().filter { $0.artistKey == key }) : try await plex.artistAlbums(key)
let shuffle = ((try? await tracksOf(id))?.isEmpty == false) ? [shuffleItem(id, "Shuffle artist")] : []
return shuffle + albums.map { albumItem($0) }
}
/// Away from home, the library is what's downloaded.
private func albumsFor(_ away: Bool) async throws -> [Album] {
away ? albumsOf(downloadedTracks()).sorted { $0.title.lowercased() < $1.title.lowercased() } : try await plex.albums()
}
private func artistsFor(_ away: Bool) async throws -> [Artist] {
guard away else { return try await plex.artists() }
var seen = Set<String>()
return downloadedTracks().compactMap { t -> Artist? in
guard let k = t.artistKey, seen.insert(k).inserted else { return nil }
return Artist(ratingKey: k, title: t.artist, sortTitle: t.artist)
}.sorted { $0.sortTitle.lowercased() < $1.sortTitle.lowercased() }
}
private func albumsOf(_ tracks: [Track]) -> [Album] {
var seen = Set<String>()
return tracks.compactMap { t in
guard let k = t.albumKey, seen.insert(k).inserted else { return nil }
return Album(ratingKey: k, title: t.album, artist: t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb)
}
}
func downloadedTracks() -> [Track] {
downloads.records.values.filter(\.done).map(\.track).sorted {
($0.artist.lowercased(), $0.album.lowercased(), $0.disc ?? 0, $0.index ?? 0) <
($1.artist.lowercased(), $1.album.lowercased(), $1.disc ?? 0, $1.index ?? 0)
}
}
/// A container's songs, minus those that can't play right now.
private func tracksOf(_ container: String) async throws -> [Track] {
let now = DispatchTime.now().uptimeNanoseconds
let away = await away()
// Home and away lists differ (away keeps only what plays offline), so they cache apart.
let ck = "\(away)|\(container)"
if let hit = lock.with({ trackCache[ck] }), now - hit.at < Self.cacheNs, !hit.tracks.isEmpty { return hit.tracks }
let all: [Track]
if container == CarIds.downloads {
all = downloadedTracks()
} else {
guard let (kind, key) = CarIds.split(container) else { return [] }
switch kind {
case "playlist": all = try await plex.playlistTracks(key)
case "album": all = try await plex.albumTracks(key)
case "artist": all = away ? downloadedTracks().filter { $0.artistKey == key } : try await plex.artistTracks(key)
case "search": all = try await searchTracks(key, away)
default: all = []
}
}
let playable = away ? await MainActor.run { all.filter { pm.playableOffline($0) } } : all
lock.with {
trackCache[ck] = Cached(at: now, tracks: playable)
order.removeAll { $0 == ck }
order.append(ck)
if order.count > 24 { trackCache[order.removeFirst()] = nil }
}
return playable
}
// ---- picks -----------------------------------------------------------------------
/// Turns a tapped row into a queue.
func resolve(_ id: String) async throws -> Resolved? {
guard let pick = CarIds.parsePick(id) else { return nil }
let tracks = try await tracksOf(pick.container)
if tracks.isEmpty { return nil }
switch pick {
case .shuffle: return Resolved(tracks: tracks, shuffle: true)
case .song(_, let key): return Resolved(tracks: tracks, start: tracks.firstIndex { $0.ratingKey == key } ?? 0)
}
}
/// "Hey Siri, play … on Stemify". Prefers a playlist, then an artist (shuffled), then an
/// album whose name matches; otherwise plays the song hits. Away from home it searches downloads.
func resolveSearch(_ query: String) async throws -> Resolved? {
if query.trimmingCharacters(in: .whitespaces).isEmpty {
let dl = try await tracksOf(CarIds.downloads)
return dl.isEmpty ? nil : Resolved(tracks: dl, shuffle: true)
}
if await away() {
let hits = offlineSearch(query)
return hits.isEmpty ? nil : Resolved(tracks: hits)
}
let r = try await plex.search(query)
if let p = r.playlists.first(where: { CarTree.matches($0.title, query) }),
let t = try? await tracksOf(CarIds.playlist(p.ratingKey)), !t.isEmpty { return Resolved(tracks: t) }
if let a = r.artists.first(where: { CarTree.matches($0.title, query) }),
let t = try? await tracksOf(CarIds.artist(a.ratingKey)), !t.isEmpty { return Resolved(tracks: t, shuffle: true) }
if let a = r.albums.first(where: { CarTree.matches($0.title, query) }),
let t = try? await tracksOf(CarIds.album(a.ratingKey)), !t.isEmpty { return Resolved(tracks: t) }
if !r.tracks.isEmpty { return Resolved(tracks: r.tracks) }
if let p = r.playlists.first { return Resolved(tracks: try await tracksOf(CarIds.playlist(p.ratingKey))) }
if let a = r.artists.first { return Resolved(tracks: try await tracksOf(CarIds.artist(a.ratingKey)), shuffle: true) }
if let a = r.albums.first { return Resolved(tracks: try await tracksOf(CarIds.album(a.ratingKey))) }
return nil
}
private func searchTracks(_ query: String, _ away: Bool) async throws -> [Track] {
away ? offlineSearch(query) : try await plex.search(query).tracks
}
func offlineSearch(_ query: String) -> [Track] {
let q = CarTree.norm(query)
if q.isEmpty { return [] }
let dl = downloadedTracks()
let byTitle = dl.filter { CarTree.norm($0.title).contains(q) }
let byOther = dl.filter { !CarTree.norm($0.title).contains(q) && (CarTree.norm($0.artist).contains(q) || CarTree.norm($0.album).contains(q)) }
return byTitle + byOther
}
/// Results for a search (from Siri, or the phone's last query).
func searchItems(_ query: String) async throws -> [CarItem] {
let container = CarIds.search(query)
if await away() { return await songs(container, try tracksOf(container), shuffleRow: false, showArtist: true, group: "Downloaded songs") }
let r = try await plex.search(query)
return await songs(container, try tracksOf(container), shuffleRow: false, showArtist: true, group: "Songs") +
r.artists.map { artistItem($0, group: "Artists") } +
r.albums.map { albumItem($0, group: "Albums") } +
r.playlists.map { playlistItem($0, group: "Playlists") }
}
// ---- items -----------------------------------------------------------------------
private func songs(_ container: String, _ tracks: [Track], shuffleRow: Bool, showArtist: Bool, group: String? = nil) async -> [CarItem] {
let rows = await MainActor.run { tracks.map { songItem(container, $0, showArtist, group) } }
return shuffleRow && tracks.count > 1 ? [shuffleItem(container, "Shuffle all", group)] + rows : rows
}
private func songItem(_ container: String, _ t: Track, _ showArtist: Bool, _ group: String?) -> CarItem {
// Stems are the norm, so only the exceptions are marked.
let noStems = stems.index.size > 0 && !pm.hasStems(t)
let sub = [noStems ? "No stems" : nil, showArtist ? t.artist : nil].compactMap { $0 }.joined(separator: " · ")
return CarItem(id: CarIds.play(container, t.ratingKey), title: t.title, subtitle: sub.isEmpty ? nil : sub,
art: t.thumb.map { .thumb($0) } ?? .letter(CarTree.letterOf(t.album)), playable: true, group: group,
downloaded: pm.playableOffline(t), trackKey: t.ratingKey)
}
private func shuffleItem(_ container: String, _ title: String, _ group: String? = nil) -> CarItem {
CarItem(id: CarIds.shuffle(container), title: title, art: .shuffle, playable: true, group: group)
}
private func playlistItem(_ p: Playlist, group: String? = nil) -> CarItem {
CarItem(id: CarIds.playlist(p.ratingKey), title: p.title, subtitle: CarTree.countLabel(p.trackCount, "song", "songs"),
art: artOr(p.thumb, p.title), playable: false, group: group)
}
private func albumItem(_ a: Album, group: String? = nil) -> CarItem {
CarItem(id: CarIds.album(a.ratingKey), title: a.title, subtitle: a.artist, art: artOr(a.thumb, a.title), playable: false, group: group)
}
private func artistItem(_ a: Artist, group: String? = nil) -> CarItem {
CarItem(id: CarIds.artist(a.ratingKey), title: a.title, art: artOr(a.thumb, a.sortTitle), playable: false, group: group)
}
/// Plex art, or a letter tile so rows never sit blank.
private func artOr(_ thumb: String?, _ title: String) -> CarItem.Art {
thumb.map { .thumb($0) } ?? .letter(CarTree.letterOf(title))
}
}