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>
260 lines
11 KiB
Swift
260 lines
11 KiB
Swift
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
|
||
}
|
||
}
|