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

+88
View File
@@ -0,0 +1,88 @@
import Foundation
/// Item ids for the CarPlay tree. Containers are `kind:key`; playable rows carry the
/// container they were picked from, so choosing a song queues the rest of its list after it.
/// The container goes last because search queries can contain anything.
enum CarIds {
static let downloads = "tab:downloads"
static let playlists = "tab:playlists"
static let albums = "tab:albums"
static let artists = "tab:artists"
static let tabs = [downloads, playlists, albums, artists]
static func playlist(_ key: String) -> String { "playlist:\(key)" }
static func album(_ key: String) -> String { "album:\(key)" }
static func artist(_ key: String) -> String { "artist:\(key)" }
static func albumLetter(_ l: String) -> String { "albums:\(l)" }
static func artistLetter(_ l: String) -> String { "artists:\(l)" }
static func search(_ q: String) -> String { "search:\(q)" }
static func play(_ container: String, _ trackKey: String) -> String { "play|\(trackKey)|\(container)" }
static func shuffle(_ container: String) -> String { "shuffle|\(container)" }
enum Pick: Equatable {
case song(container: String, trackKey: String)
case shuffle(container: String)
var container: String {
switch self { case .song(let c, _), .shuffle(let c): c }
}
}
static func parsePick(_ id: String) -> Pick? {
if id.hasPrefix("play|") {
let rest = id.dropFirst(5)
guard let bar = rest.firstIndex(of: "|"), bar != rest.startIndex, rest.index(after: bar) != rest.endIndex else { return nil }
return .song(container: String(rest[rest.index(after: bar)...]), trackKey: String(rest[..<bar]))
}
if id.hasPrefix("shuffle|") {
let c = String(id.dropFirst(8))
return c.isEmpty ? nil : .shuffle(container: c)
}
return nil
}
/// (kind, key) of a container id such as `album:123`.
static func split(_ id: String) -> (String, String)? {
guard let c = id.firstIndex(of: ":"), c != id.startIndex else { return nil }
return (String(id[..<c]), String(id[id.index(after: c)...]))
}
}
enum CarTree {
/// Lists longer than this split into A–Z folders: scrolling a few dozen rows on the car
/// screen is fine, hundreds isn't (and CarPlay caps list length while driving).
static let flatMax = 40
/// Recently added albums shown above the A–Z folders.
static let recentAlbums = 12
/// "#" for anything that doesn't start with a letter; accents fold to their base letter.
static func letterOf(_ sortTitle: String) -> String {
let folded = sortTitle.trimmingCharacters(in: .whitespaces).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: nil)
guard let c = folded.unicodeScalars.first(where: { CharacterSet.alphanumerics.contains($0) }) else { return "#" }
let u = String(c).uppercased()
return u.count == 1 && ("A"..."Z").contains(u) ? u : "#"
}
/// Items by first letter, "#" first then A–Z, each keeping the incoming order.
static func buckets<T>(_ items: [T], _ sortTitle: (T) -> String) -> [(String, [T])] {
var groups: [String: [T]] = [:]
for it in items { groups[letterOf(sortTitle(it)), default: []].append(it) }
return groups.sorted { ($0.key == "#" ? "" : $0.key) < ($1.key == "#" ? "" : $1.key) }.map { ($0.key, $0.value) }
}
static func countLabel(_ n: Int, _ one: String, _ many: String) -> String { "\(n) \(n == 1 ? one : many)" }
/// Loose title match for voice requests: case, accents and punctuation don't matter.
static func norm(_ s: String) -> String {
let folded = s.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: nil).lowercased()
let kept = folded.unicodeScalars.filter { CharacterSet.alphanumerics.contains($0) || $0 == " " }
return String(String.UnicodeScalarView(kept)).split(separator: " ").joined(separator: " ")
}
static func matches(_ title: String, _ query: String) -> Bool {
let t = norm(title), q = norm(query)
func noThe(_ s: String) -> String { s.hasPrefix("the ") ? String(s.dropFirst(4)) : s }
return !q.isEmpty && (t == q || noThe(t) == noThe(q))
}
}