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

260 lines
11 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 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(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
}
}