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:
1 parent
45bc0eefe0
commit
fdf9e970b2
45 files changed
+9822
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user