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

+259
View File
@@ -0,0 +1,259 @@
import CarPlay
import UIKit
/// The CarPlay UI: a tab bar of browse lists over [CarLibrary], and the system Now Playing
/// screen with Vocals and Mix buttons next to shuffle and repeat.
@MainActor
final class CarPlayScene: UIResponder, CPTemplateApplicationSceneDelegate, CPNowPlayingTemplateObserver {
private var ic: CPInterfaceController?
private var g: AppGraph { AppGraph.shared }
private var listenerId: UUID?
private var messageId: UUID?
private var tabIds: [String] = []
private var lastHome: Bool?
private var alertTask: Task<Void, Never>?
func templateApplicationScene(_ scene: CPTemplateApplicationScene, didConnect ic: CPInterfaceController) {
self.ic = ic
let np = CPNowPlayingTemplate.shared
np.isUpNextButtonEnabled = true
np.isAlbumArtistButtonEnabled = false
np.add(self)
listenerId = g.playback.addListener { [weak self] in self?.updateNowPlaying() }
messageId = g.playback.addMessageListener { [weak self] in self?.alert($0) }
updateNowPlaying()
g.reach.onChange { [weak self] in self?.reloadRoot() }
reloadRoot(force: true)
}
func templateApplicationScene(_ scene: CPTemplateApplicationScene, didDisconnectInterfaceController ic: CPInterfaceController) {
listenerId.map(g.playback.removeListener)
listenerId = nil
messageId.map(g.playback.removeListener)
messageId = nil
CPNowPlayingTemplate.shared.remove(self)
self.ic = nil
}
// ---- browse ------------------------------------------------------------------------
/// Rebuilds the tabs when home reachability flips (Downloads moves to the front away from home).
private func reloadRoot(force: Bool = false) {
Task {
let home = await g.reach.reachable()
guard force || home != lastHome, let ic else { return }
lastHome = home
let tabs = await g.car.tabs()
tabIds = tabs.map(\.0)
let templates: [CPTemplate] = tabs.prefix(CPTabBarTemplate.maximumTabCount).map { id, title in
let t = CPListTemplate(title: title, sections: [])
t.tabTitle = title
t.tabImage = UIImage(systemName: Self.tabIcon(id))
t.userInfo = id
load(t, id)
return t
}
let bar = CPTabBarTemplate(templates: templates)
ic.setRootTemplate(bar, animated: false, completion: nil)
}
}
private static func tabIcon(_ id: String) -> String {
switch id {
case CarIds.playlists: "music.note.list"
case CarIds.albums: "square.stack"
case CarIds.artists: "music.mic"
default: "arrow.down.circle"
}
}
private func push(_ id: String, title: String) {
let t = CPListTemplate(title: title, sections: [])
t.userInfo = id
load(t, id)
ic?.pushTemplate(t, animated: true, completion: nil)
}
private func load(_ t: CPListTemplate, _ id: String) {
t.emptyViewTitleVariants = ["Loading…"]
Task {
do {
let items = try await g.car.children(id) ?? []
t.emptyViewTitleVariants = [id == CarIds.downloads ? "No downloads yet" : "Nothing here"]
t.emptyViewSubtitleVariants = id == CarIds.downloads ? ["Download music on your phone to play it anywhere"] : []
t.updateSections(sections(items))
} catch {
t.emptyViewTitleVariants = [error is NotSignedIn ? "Sign in to Plex on your phone"
: g.reach.lastKnown ? "Couldn't reach Plex" : "Not downloaded – connect to home Wi-Fi"]
t.emptyViewSubtitleVariants = []
t.updateSections([])
}
}
}
private func sections(_ items: [CarItem]) -> [CPListSection] {
let maxItems = CPListTemplate.maximumItemCount
var out: [(String?, [CPListItem])] = []
var count = 0
for it in items where count < maxItems {
let row = listItem(it)
if let last = out.last, last.0 == it.group { out[out.count - 1].1.append(row) } else { out.append((it.group, [row])) }
count += 1
}
return out.prefix(CPListTemplate.maximumSectionCount).map { header, rows in
header.map { CPListSection(items: rows, header: $0, sectionIndexTitle: nil) } ?? CPListSection(items: rows)
}
}
private func listItem(_ it: CarItem) -> CPListItem {
let row = CPListItem(text: it.title, detailText: it.subtitle, image: placeholder(it.art),
accessoryImage: it.downloaded ? UIImage(systemName: "arrow.down.circle.fill") : nil,
accessoryType: it.playable ? .none : .disclosureIndicator)
if let key = it.trackKey {
row.isPlaying = g.playback.current?.track.ratingKey == key
row.playingIndicatorLocation = .trailing
}
if case .thumb(let thumb) = it.art, let url = g.carPlex.artUrl(thumb, px: 240) {
if let img = ArtLoader.shared.cached(url) {
row.setImage(img)
} else {
Task { if let img = await ArtLoader.shared.image(url) { row.setImage(img) } }
}
}
row.handler = { [weak self] _, done in
guard let self else { return done() }
if it.playable { self.pick(it.id, done) } else { self.push(it.id, title: it.title); done() }
}
return row
}
private func placeholder(_ art: CarItem.Art) -> UIImage? {
switch art {
case .none: nil
case .shuffle: UIImage(systemName: "shuffle")
case .letter(let l): Self.letterTile(l)
case .thumb: nil
}
}
private func pick(_ id: String, _ done: @escaping () -> Void) {
Task {
do {
if let r = try await g.car.resolve(id) {
g.playback.playTracks(r.tracks, start: r.start, shuffle: r.shuffle)
showNowPlaying()
} else {
g.playback.report("That isn't available offline")
}
} catch {
g.playback.report(error is NotSignedIn ? "Sign in to Plex on your phone" : "Couldn't load that: \(error.localizedDescription)")
}
done()
}
}
private func showNowPlaying() {
guard let ic, ic.topTemplate !== CPNowPlayingTemplate.shared else { return }
ic.pushTemplate(CPNowPlayingTemplate.shared, animated: true, completion: nil)
}
// ---- now playing -------------------------------------------------------------------
private func updateNowPlaying() {
let np = g.nowPlaying, pm = g.playback
var buttons: [CPNowPlayingButton] = []
if np.vocalsAvailable {
let silent = np.vocalsSilent
let img = UIImage(systemName: silent ? "mic.slash.fill" : "mic.fill")!
buttons.append(CPNowPlayingImageButton(image: img) { [weak self] _ in self?.g.nowPlaying.toggleVocals() })
}
if pm.source == .Stems && pm.stemNames.count > 1 {
buttons.append(CPNowPlayingImageButton(image: UIImage(systemName: "slider.vertical.3")!) { [weak self] _ in
guard let self else { return }
let label = self.g.nowPlaying.nextMix.label
self.g.nowPlaying.cycleMix()
self.alert("Mix: \(label)")
})
}
let shuffle = CPNowPlayingShuffleButton { [weak self] _ in self?.g.playback.toggleShuffle() }
shuffle.isSelected = pm.shuffle
let repeatButton = CPNowPlayingRepeatButton { [weak self] _ in self?.g.playback.cycleRepeat() }
repeatButton.isSelected = pm.repeatMode != .Off
buttons += [shuffle, repeatButton]
CPNowPlayingTemplate.shared.updateNowPlayingButtons(buttons)
CPNowPlayingTemplate.shared.isUpNextButtonEnabled = pm.queue.count > 1
}
nonisolated func nowPlayingTemplateUpNextButtonTapped(_ nowPlayingTemplate: CPNowPlayingTemplate) {
MainActor.assumeIsolated { showQueue() }
}
nonisolated func nowPlayingTemplateAlbumArtistButtonTapped(_ nowPlayingTemplate: CPNowPlayingTemplate) {}
private func showQueue() {
let pm = g.playback
let start = max(pm.index, 0)
let rows: [CPListItem] = pm.queue.enumerated().dropFirst(start).prefix(CPListTemplate.maximumItemCount).map { i, item in
let t = item.track
let row = CPListItem(text: t.title, detailText: t.artist)
row.isPlaying = i == pm.index
if let url = g.plex.artUrl(t.thumb, px: 240) {
Task { if let img = await ArtLoader.shared.image(url) { row.setImage(img) } }
}
row.handler = { [weak self] _, done in
self?.g.playback.skipTo(i)
self?.ic?.popTemplate(animated: true, completion: nil)
done()
}
return row
}
let t = CPListTemplate(title: "Up Next", sections: [CPListSection(items: rows)])
ic?.pushTemplate(t, animated: true, completion: nil)
}
// ---- messages ----------------------------------------------------------------------
/// Toasts on the phone become a short alert in the car.
private func alert(_ text: String) {
guard let ic else { return }
alertTask?.cancel()
// Weak all the way down: the scene can disconnect while the alert is up.
let show = { [weak self] in
guard let self, let ic = self.ic else { return }
let a = CPAlertTemplate(titleVariants: [text], actions: [CPAlertAction(title: "OK", style: .cancel) { [weak self] _ in
self?.ic?.dismissTemplate(animated: true, completion: nil)
}])
ic.presentTemplate(a, animated: true, completion: nil)
self.alertTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 4_000_000_000)
if !Task.isCancelled, let ic = self?.ic, ic.presentedTemplate === a { ic.dismissTemplate(animated: true, completion: nil) }
}
}
if ic.presentedTemplate != nil {
ic.dismissTemplate(animated: false) { _, _ in show() }
} else {
show()
}
}
// ---- art ---------------------------------------------------------------------------
private static var tiles: [String: UIImage] = [:]
/// A big white letter on a flat dark tile, readable at a glance on the car screen.
static func letterTile(_ letter: String) -> UIImage {
let l = letter.prefix(1).uppercased().isEmpty ? "#" : letter.prefix(1).uppercased()
if let img = tiles[l] { return img }
let px: CGFloat = 120
let img = UIGraphicsImageRenderer(size: CGSize(width: px, height: px)).image { ctx in
UIColor(red: 0x2C / 255, green: 0x2C / 255, blue: 0x2E / 255, alpha: 1).setFill()
ctx.fill(CGRect(x: 0, y: 0, width: px, height: px))
let attrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: px * 0.55, weight: .bold), .foregroundColor: UIColor.white]
let s = NSString(string: l)
let size = s.size(withAttributes: attrs)
s.draw(at: CGPoint(x: (px - size.width) / 2, y: (px - size.height) / 2), withAttributes: attrs)
}
tiles[l] = img
return img
}
}