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

+286
View File
@@ -0,0 +1,286 @@
import Foundation
/// What a pick or a voice request turns into: a queue, where to start, and whether to shuffle it.
struct Resolved {
var tracks: [Track]
var start = 0
var shuffle = false
}
/// A row in the car's lists; CarPlayScene turns these into CPListItems.
struct CarItem {
enum Art: Equatable {
case none
case thumb(String)
case letter(String)
case shuffle
}
var id: String
var title: String
var subtitle: String?
var art: Art = .none
var playable: Bool
/// Section header the row sits under, nil for the list's first unnamed section.
var group: String?
var downloaded = false
var trackKey: String?
}
/// The browse tree the car draws. CarPlay renders the lists itself, so designing for the car
/// means shaping this tree: four tabs, shallow folders, Shuffle as the first row, A–Z folders
/// instead of long lists.
///
/// Away from home (Plex and stemserve unreachable) everything comes from the HTTP cache and
/// only songs that will actually play offline are listed, with Downloads as the first tab.
final class CarLibrary: @unchecked Sendable {
private static let cacheNs: UInt64 = 120_000_000_000
private let plex: PlexClient
private let reach: HomeReach
private let downloads: Downloads
private let stems: StemServer
private let pm: PlaybackManager
private struct Cached { let at: UInt64; let tracks: [Track] }
private let lock = UnfairLock()
private var trackCache: [String: Cached] = [:]
private var order: [String] = []
init(plex: PlexClient, reach: HomeReach, downloads: Downloads, stems: StemServer, pm: PlaybackManager) {
self.plex = plex
self.reach = reach
self.downloads = downloads
self.stems = stems
self.pm = pm
}
private func away() async -> Bool { !(await reach.reachable()) }
// ---- tree -----------------------------------------------------------------------
/// Tab ids and titles, Downloads first when away from home.
func tabs() async -> [(String, String)] {
let tabs = [(CarIds.playlists, "Playlists"), (CarIds.albums, "Albums"), (CarIds.artists, "Artists"), (CarIds.downloads, "Downloads")]
return await away() ? [tabs[3]] + tabs.prefix(3) : tabs
}
/// Children of [id], or nil if the id means nothing to us.
func children(_ id: String) async throws -> [CarItem]? {
let away = await away()
switch id {
case CarIds.downloads: return await songs(CarIds.downloads, try tracksOf(CarIds.downloads), shuffleRow: true, showArtist: true)
case CarIds.playlists: return try await playlists(away)
case CarIds.albums: return try await albumsTab(away)
case CarIds.artists: return try await artistsTab(away)
default:
guard let (kind, key) = CarIds.split(id) else { return nil }
switch kind {
case "playlist": return await songs(id, try tracksOf(id), shuffleRow: true, showArtist: true)
case "album": return await songs(id, try tracksOf(id), shuffleRow: false, showArtist: false)
case "artist": return try await artistPage(key, away)
case "albums": return try await albumsFor(away).filter { CarTree.letterOf($0.title) == key }.map { albumItem($0) }
case "artists": return try await artistsFor(away).filter { CarTree.letterOf($0.sortTitle) == key }.map { artistItem($0) }
case "search": return try await searchItems(key)
default: return nil
}
}
}
private func playlists(_ away: Bool) async throws -> [CarItem] {
let all = try await plex.playlists()
var shown: [Playlist] = []
if away {
for p in all where ((try? await tracksOf(CarIds.playlist(p.ratingKey)))?.isEmpty == false) { shown.append(p) }
} else {
shown = all
}
return shown.map { playlistItem($0) }
}
private func albumsTab(_ away: Bool) async throws -> [CarItem] {
let albums = try await albumsFor(away)
if away || albums.count <= CarTree.flatMax { return albums.map { albumItem($0) } }
let recent = (try? await plex.recentlyAdded(count: CarTree.recentAlbums)) ?? []
return recent.map { albumItem($0, group: "Recently added") } +
CarTree.buckets(albums, \.title).map { letter, list in
CarItem(id: CarIds.albumLetter(letter), title: letter, subtitle: CarTree.countLabel(list.count, "album", "albums"),
art: .letter(letter), playable: false, group: "All albums")
}
}
private func artistsTab(_ away: Bool) async throws -> [CarItem] {
let artists = try await artistsFor(away)
if artists.count <= CarTree.flatMax { return artists.map { artistItem($0) } }
return CarTree.buckets(artists, \.sortTitle).map { letter, list in
CarItem(id: CarIds.artistLetter(letter), title: letter, subtitle: CarTree.countLabel(list.count, "artist", "artists"),
art: .letter(letter), playable: false)
}
}
private func artistPage(_ key: String, _ away: Bool) async throws -> [CarItem] {
let id = CarIds.artist(key)
let albums = away ? albumsOf(downloadedTracks().filter { $0.artistKey == key }) : try await plex.artistAlbums(key)
let shuffle = ((try? await tracksOf(id))?.isEmpty == false) ? [shuffleItem(id, "Shuffle artist")] : []
return shuffle + albums.map { albumItem($0) }
}
/// Away from home, the library is what's downloaded.
private func albumsFor(_ away: Bool) async throws -> [Album] {
away ? albumsOf(downloadedTracks()).sorted { $0.title.lowercased() < $1.title.lowercased() } : try await plex.albums()
}
private func artistsFor(_ away: Bool) async throws -> [Artist] {
guard away else { return try await plex.artists() }
var seen = Set<String>()
return downloadedTracks().compactMap { t -> Artist? in
guard let k = t.artistKey, seen.insert(k).inserted else { return nil }
return Artist(ratingKey: k, title: t.artist, sortTitle: t.artist)
}.sorted { $0.sortTitle.lowercased() < $1.sortTitle.lowercased() }
}
private func albumsOf(_ tracks: [Track]) -> [Album] {
var seen = Set<String>()
return tracks.compactMap { t in
guard let k = t.albumKey, seen.insert(k).inserted else { return nil }
return Album(ratingKey: k, title: t.album, artist: t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb)
}
}
func downloadedTracks() -> [Track] {
downloads.records.values.filter(\.done).map(\.track).sorted {
($0.artist.lowercased(), $0.album.lowercased(), $0.disc ?? 0, $0.index ?? 0) <
($1.artist.lowercased(), $1.album.lowercased(), $1.disc ?? 0, $1.index ?? 0)
}
}
/// A container's songs, minus those that can't play right now.
private func tracksOf(_ container: String) async throws -> [Track] {
let now = DispatchTime.now().uptimeNanoseconds
let away = await away()
// Home and away lists differ (away keeps only what plays offline), so they cache apart.
let ck = "\(away)|\(container)"
if let hit = lock.with({ trackCache[ck] }), now - hit.at < Self.cacheNs, !hit.tracks.isEmpty { return hit.tracks }
let all: [Track]
if container == CarIds.downloads {
all = downloadedTracks()
} else {
guard let (kind, key) = CarIds.split(container) else { return [] }
switch kind {
case "playlist": all = try await plex.playlistTracks(key)
case "album": all = try await plex.albumTracks(key)
case "artist": all = away ? downloadedTracks().filter { $0.artistKey == key } : try await plex.artistTracks(key)
case "search": all = try await searchTracks(key, away)
default: all = []
}
}
let playable = away ? await MainActor.run { all.filter { pm.playableOffline($0) } } : all
lock.with {
trackCache[ck] = Cached(at: now, tracks: playable)
order.removeAll { $0 == ck }
order.append(ck)
if order.count > 24 { trackCache[order.removeFirst()] = nil }
}
return playable
}
// ---- picks -----------------------------------------------------------------------
/// Turns a tapped row into a queue.
func resolve(_ id: String) async throws -> Resolved? {
guard let pick = CarIds.parsePick(id) else { return nil }
let tracks = try await tracksOf(pick.container)
if tracks.isEmpty { return nil }
switch pick {
case .shuffle: return Resolved(tracks: tracks, shuffle: true)
case .song(_, let key): return Resolved(tracks: tracks, start: tracks.firstIndex { $0.ratingKey == key } ?? 0)
}
}
/// "Hey Siri, play … on Stemify". Prefers a playlist, then an artist (shuffled), then an
/// album whose name matches; otherwise plays the song hits. Away from home it searches downloads.
func resolveSearch(_ query: String) async throws -> Resolved? {
if query.trimmingCharacters(in: .whitespaces).isEmpty {
let dl = try await tracksOf(CarIds.downloads)
return dl.isEmpty ? nil : Resolved(tracks: dl, shuffle: true)
}
if await away() {
let hits = offlineSearch(query)
return hits.isEmpty ? nil : Resolved(tracks: hits)
}
let r = try await plex.search(query)
if let p = r.playlists.first(where: { CarTree.matches($0.title, query) }),
let t = try? await tracksOf(CarIds.playlist(p.ratingKey)), !t.isEmpty { return Resolved(tracks: t) }
if let a = r.artists.first(where: { CarTree.matches($0.title, query) }),
let t = try? await tracksOf(CarIds.artist(a.ratingKey)), !t.isEmpty { return Resolved(tracks: t, shuffle: true) }
if let a = r.albums.first(where: { CarTree.matches($0.title, query) }),
let t = try? await tracksOf(CarIds.album(a.ratingKey)), !t.isEmpty { return Resolved(tracks: t) }
if !r.tracks.isEmpty { return Resolved(tracks: r.tracks) }
if let p = r.playlists.first { return Resolved(tracks: try await tracksOf(CarIds.playlist(p.ratingKey))) }
if let a = r.artists.first { return Resolved(tracks: try await tracksOf(CarIds.artist(a.ratingKey)), shuffle: true) }
if let a = r.albums.first { return Resolved(tracks: try await tracksOf(CarIds.album(a.ratingKey))) }
return nil
}
private func searchTracks(_ query: String, _ away: Bool) async throws -> [Track] {
away ? offlineSearch(query) : try await plex.search(query).tracks
}
func offlineSearch(_ query: String) -> [Track] {
let q = CarTree.norm(query)
if q.isEmpty { return [] }
let dl = downloadedTracks()
let byTitle = dl.filter { CarTree.norm($0.title).contains(q) }
let byOther = dl.filter { !CarTree.norm($0.title).contains(q) && (CarTree.norm($0.artist).contains(q) || CarTree.norm($0.album).contains(q)) }
return byTitle + byOther
}
/// Results for a search (from Siri, or the phone's last query).
func searchItems(_ query: String) async throws -> [CarItem] {
let container = CarIds.search(query)
if await away() { return await songs(container, try tracksOf(container), shuffleRow: false, showArtist: true, group: "Downloaded songs") }
let r = try await plex.search(query)
return await songs(container, try tracksOf(container), shuffleRow: false, showArtist: true, group: "Songs") +
r.artists.map { artistItem($0, group: "Artists") } +
r.albums.map { albumItem($0, group: "Albums") } +
r.playlists.map { playlistItem($0, group: "Playlists") }
}
// ---- items -----------------------------------------------------------------------
private func songs(_ container: String, _ tracks: [Track], shuffleRow: Bool, showArtist: Bool, group: String? = nil) async -> [CarItem] {
let rows = await MainActor.run { tracks.map { songItem(container, $0, showArtist, group) } }
return shuffleRow && tracks.count > 1 ? [shuffleItem(container, "Shuffle all", group)] + rows : rows
}
private func songItem(_ container: String, _ t: Track, _ showArtist: Bool, _ group: String?) -> CarItem {
// Stems are the norm, so only the exceptions are marked.
let noStems = stems.index.size > 0 && !pm.hasStems(t)
let sub = [noStems ? "No stems" : nil, showArtist ? t.artist : nil].compactMap { $0 }.joined(separator: " · ")
return CarItem(id: CarIds.play(container, t.ratingKey), title: t.title, subtitle: sub.isEmpty ? nil : sub,
art: t.thumb.map { .thumb($0) } ?? .letter(CarTree.letterOf(t.album)), playable: true, group: group,
downloaded: pm.playableOffline(t), trackKey: t.ratingKey)
}
private func shuffleItem(_ container: String, _ title: String, _ group: String? = nil) -> CarItem {
CarItem(id: CarIds.shuffle(container), title: title, art: .shuffle, playable: true, group: group)
}
private func playlistItem(_ p: Playlist, group: String? = nil) -> CarItem {
CarItem(id: CarIds.playlist(p.ratingKey), title: p.title, subtitle: CarTree.countLabel(p.trackCount, "song", "songs"),
art: artOr(p.thumb, p.title), playable: false, group: group)
}
private func albumItem(_ a: Album, group: String? = nil) -> CarItem {
CarItem(id: CarIds.album(a.ratingKey), title: a.title, subtitle: a.artist, art: artOr(a.thumb, a.title), playable: false, group: group)
}
private func artistItem(_ a: Artist, group: String? = nil) -> CarItem {
CarItem(id: CarIds.artist(a.ratingKey), title: a.title, art: artOr(a.thumb, a.sortTitle), playable: false, group: group)
}
/// Plex art, or a letter tile so rows never sit blank.
private func artOr(_ thumb: String?, _ title: String) -> CarItem.Art {
thumb.map { .thumb($0) } ?? .letter(CarTree.letterOf(title))
}
}
+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
}
}
+88
View File
@@ -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))
}
}
+73
View File
@@ -0,0 +1,73 @@
import Foundation
import Observation
/// Whether Plex or stemserve answers right now. In the car the phone is usually on cellular
/// and the LAN addresses just time out, so the car UI asks this first (a quick probe,
/// remembered briefly) and goes straight to cached data when home is out of reach.
@Observable
final class HomeReach: @unchecked Sendable {
private static let ttl: UInt64 = 20_000_000_000
@ObservationIgnored private let settings: Settings
@ObservationIgnored private let session: URLSession
@ObservationIgnored private let lock = AsyncLock()
@ObservationIgnored private let stamp = UnfairLock()
@ObservationIgnored private var checkedAt: UInt64 = 0
@ObservationIgnored private var _last = true
@ObservationIgnored private var listeners: [() -> Void] = []
/// The last answer; true until a probe says otherwise. Changes as the phone joins or leaves home Wi-Fi.
private(set) var home = true
/// Readable from any thread (the car's Plex client asks it on every request).
var lastKnown: Bool { stamp.with { _last } }
init(settings: Settings, net: Connectivity) {
self.settings = settings
let c = URLSessionConfiguration.ephemeral
c.timeoutIntervalForRequest = 2
c.timeoutIntervalForResource = 2.5
c.waitsForConnectivity = false
session = URLSession(configuration: c)
// A new network (home Wi-Fi in range, or lost) invalidates the answer; ask again once it
// settles, so the car's lists can switch over without anyone browsing first.
net.onChange { [weak self] in
guard let self else { return }
self.stamp.with { self.checkedAt = 0 }
Task {
try? await Task.sleep(nanoseconds: 1_500_000_000)
_ = await self.reachable()
}
}
}
/// Main-thread callback when [home] changes.
func onChange(_ f: @escaping () -> Void) { listeners.append(f) }
func reachable() async -> Bool {
await lock.run {
let now = DispatchTime.now().uptimeNanoseconds
let (at, last) = stamp.with { (checkedAt, _last) }
if at != 0 && now - at < Self.ttl { return last }
let plex = settings.plexUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/identity"
let stems = settings.stemUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/info"
async let a = answers(plex)
async let b = answers(stems)
let (ra, rb) = await (a, b)
let ok = ra || rb
stamp.with { _last = ok; checkedAt = DispatchTime.now().uptimeNanoseconds }
await MainActor.run {
if home != ok {
home = ok
listeners.forEach { $0() }
}
}
return ok
}
}
private func answers(_ s: String) async -> Bool {
guard let url = URL(string: s), let (_, r) = try? await session.data(from: url) else { return false }
return ((r as? HTTPURLResponse)?.statusCode ?? 500) < 500
}
}