Files
stemify/ios/Stemify/CarPlay/CarLibrary.swift
T

348 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 "dlalbum": return await songs(id, try tracksOf(id), shuffleRow: false, showArtist: false)
case "dlartist": return await songs(id, try tracksOf(id), shuffleRow: true, showArtist: false)
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 await downloadedPlaylists()
case "albums":
return albumsOf(downloadedTracks()).sorted { $0.title.lowercased() < $1.title.lowercased() }.map { a in
CarItem(id: "dlalbum:\(a.ratingKey)", title: a.title, subtitle: a.artist, art: artOr(a.thumb, a.title), playable: false)
}
case "artists":
return (try await artistsFor(true)).map { a in
CarItem(id: "dlartist:\(a.ratingKey)", title: a.title, art: artOr(a.thumb, a.sortTitle), playable: false)
}
case "songs": return await songs(CarIds.downloads, try tracksOf(CarIds.downloads), shuffleRow: true, showArtist: true)
default: return nil
}
}
/// Local only: stem playlists with a downloaded song, plus Plex playlists if the server answers.
private func downloadedPlaylists() async -> [CarItem] {
let have = Set(downloadedTracks().map(\.ratingKey))
let stem = await MainActor.run { AppGraph.shared.stemPlaylists.lists.filter { l in l.tracks.contains { have.contains($0.ratingKey) } } }
var out = 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")
}
if let all = try? await plex.playlists() {
for p in all where ((try? await plex.playlistTracks(p.ratingKey))?.contains { have.contains($0.ratingKey) } == true) {
out.append(playlistItem(p, group: "Plex Playlists"))
}
}
return out
}
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 "dlalbum": all = downloadedTracks().filter { $0.albumKey == key }
case "dlartist": all = downloadedTracks().filter { $0.artistKey == 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))
}
}