Files
cdnutterandClaude Opus 5.5 fdf9e970b2 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>
2026-10-20 07:15:47 -07:00

89 lines
4.2 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}
}