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