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? 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 } }