322 lines
16 KiB
Swift
322 lines
16 KiB
Swift
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 downloadsMenu()
|
||
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 "dl": return try await downloadsView(key)
|
||
case "playlist", "stemplaylist": 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
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Downloads opens onto the same categories as the other tabs, each limited to what's downloaded.
|
||
private func downloadsMenu() -> [CarItem] {
|
||
let dl = downloadedTracks()
|
||
guard !dl.isEmpty else { return [] }
|
||
return [
|
||
CarItem(id: "dl:playlists", title: "Playlists", art: .letter("P"), playable: false),
|
||
CarItem(id: "dl:albums", title: "Albums", subtitle: CarTree.countLabel(albumsOf(dl).count, "album", "albums"), art: .letter("A"), playable: false),
|
||
CarItem(id: "dl:artists", title: "Artists", subtitle: CarTree.countLabel(Set(dl.compactMap(\.artistKey)).count, "artist", "artists"), art: .letter("A"), playable: false),
|
||
CarItem(id: "dl:songs", title: "Songs", subtitle: CarTree.countLabel(dl.count, "song", "songs"), art: .letter("S"), playable: false),
|
||
]
|
||
}
|
||
|
||
private func downloadsView(_ key: String) async throws -> [CarItem]? {
|
||
switch key {
|
||
case "playlists": return try await playlists(true, onlyDownloaded: true)
|
||
case "albums": return try await albumsFor(true).map { albumItem($0) }
|
||
case "artists": return try await artistsFor(true).map { artistItem($0) }
|
||
case "songs": return await songs(CarIds.downloads, try tracksOf(CarIds.downloads), shuffleRow: true, showArtist: true)
|
||
default: return nil
|
||
}
|
||
}
|
||
|
||
private func playlists(_ away: Bool, onlyDownloaded: Bool = false) async throws -> [CarItem] {
|
||
let all = try await plex.playlists()
|
||
var shown: [Playlist] = []
|
||
if onlyDownloaded {
|
||
let have = Set(downloadedTracks().map(\.ratingKey))
|
||
for p in all where ((try? await plex.playlistTracks(p.ratingKey))?.contains { have.contains($0.ratingKey) } == true) { shown.append(p) }
|
||
} else if away {
|
||
for p in all where ((try? await tracksOf(CarIds.playlist(p.ratingKey)))?.isEmpty == false) { shown.append(p) }
|
||
} else {
|
||
shown = all
|
||
}
|
||
let have = Set(downloadedTracks().map(\.ratingKey))
|
||
let stem = await MainActor.run { AppGraph.shared.stemPlaylists.lists.filter { l in
|
||
onlyDownloaded ? (l.tracks.contains { have.contains($0.ratingKey) }) : !l.items.isEmpty } }
|
||
let stemItems = stem.map { l in
|
||
CarItem(id: "stemplaylist:\(l.id)", title: l.title, subtitle: CarTree.countLabel(l.items.count, "song", "songs"),
|
||
art: artOr(l.thumb, l.title), playable: false, group: "Stem Playlists")
|
||
}
|
||
let plexItems = shown.map { playlistItem($0, group: stemItems.isEmpty ? nil : "Plex Playlists") }
|
||
return stemItems + plexItems
|
||
}
|
||
|
||
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 "stemplaylist": all = await MainActor.run { AppGraph.shared.stemPlaylists.list(key)?.tracks ?? [] }
|
||
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 ? (stems.state(t) == .stemming ? "Stemming" : stems.state(t) == .queued ? "Queued for stems" : "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))
|
||
}
|
||
}
|