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,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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user