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