Files
ipx/ios/Sources/CarScreen.swift
rays 6d6bfe6e1d iOS CarPlay: the phone's bar takes up the car's episode; only playable shows (#442, #443, #444)
#442: a car's pick told the page `replaced`, and the page closed its player bar, so leaving the
car with the episode playing left nothing on the phone to pause it. And when the car launched the
app, the page loaded later and was never told anything played. Now the app sends `nowPlaying`
(the item, its place, playing or not) on a car pick, at the page's `ready`, and on coming
forward, and the page's bar takes it up and drives the phone's player. `replaced` is still
handled for builds before this one.

#443: Shows listed every feed with items, blogs included. Now only feeds with a downloaded file,
and an episode only with a downloaded audio file: /media/{id} answers 404 before the download.

#444: tests/native-bridge.ts read its failure count before the CarPlay checks, so those could
fail and still exit 0. The count is read last now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
2026-11-11 23:08:14 +00:00

246 lines
11 KiB
Swift

import CarPlay
import UIKit
// CarPlay (#420). A car shows no web view, so this is the app's own: three lists from ipx's API,
// In Progress, Unread and Shows, read as the badge reads it (API), and the same Playback the
// page drives, so a car's pick plays on with the phone locked and saves its place as the page's
// does. Now Playing is the system's, fed by Playback's now-playing info and remote commands.
/// An item as a car's list needs it: db.rs EntryRow, the fields used.
struct CarEpisode: Decodable {
struct Enclosure: Decodable {
let id: Int
let url: String
let mime: String?
let deadair: Double?
/// Set once ipx has the file; `/media/{id}` answers 404 before that.
let path: String?
}
let guid: String
let feed_id: String
let title: String?
let image: String?
let duration: Double?
let position: Double
let enclosures: [Enclosure]
/// The file to play: the first downloaded one that is sound (items.ts isPlayable, less video,
/// which a car would play the sound of with nothing to say so). An item with none is not listed
/// (#443): a post's picture or a file still to come is nothing a car can play.
var audio: Enclosure? {
enclosures.first { e in
guard e.path != nil else { return false }
let mime = (e.mime ?? "").lowercased()
if !mime.isEmpty { return mime.hasPrefix("audio/") }
let ext = URL(string: e.url)?.pathExtension.lowercased() ?? ""
return ["mp3", "m4a", "m4b", "aac", "wav", "flac"].contains(ext)
}
}
/// Its title, or the file's name: a car's list cannot show the opening of a post as the
/// page's does, and "Untitled" twelve times says nothing.
var name: String {
if let t = title?.trimmingCharacters(in: .whitespacesAndNewlines), !t.isEmpty { return t }
let file = audio.flatMap { URL(string: $0.url)?.deletingPathExtension().lastPathComponent }
return file.flatMap { $0.isEmpty ? nil : $0.removingPercentEncoding ?? $0 } ?? "Untitled episode"
}
/// player.ts `mediaSrc`: the copy kept on the phone if there is one, else DeadAir's when it
/// made one, so the clock the place is saved by is the one the page would use.
static func path(of e: Enclosure, kept: Set<String>) -> String {
let keepPath = (e.deadair ?? 0) > 0 ? "/media/\(e.id)/deadair" : "/media/\(e.id)"
if kept.contains(keepPath) { return keepPath }
return e.deadair != nil ? "/media/\(e.id)/deadair.m3u8" : "/media/\(e.id)"
}
/// What is left of it, once started: "12 min left".
var left: String? {
guard position > 5, let d = duration, d > position else { return nil }
return "\(max(1, Int(((d - position) / 60).rounded()))) min left"
}
func item(feedTitle: String, feedImage: String?, kept: Set<String>) -> Playback.Item? {
guard let enc = audio, let url = Server.resolveForPlayer(Self.path(of: enc, kept: kept)) else { return nil }
return Playback.Item(url: url, enc: enc.id, feedId: feed_id, guid: guid, title: name,
feedTitle: feedTitle, artwork: image ?? feedImage,
duration: duration.map { Int($0) })
}
}
/// A subscription, from `/api/feeds`.
struct CarFeed: Decodable {
let id: String
let title: String?
let image: String?
let unread: Int?
let entries: Int?
/// Its files ipx has downloaded.
let downloaded: Int?
let group: String?
var name: String { title ?? id }
}
private struct CarPage: Decodable {
let entries: [CarEpisode]
}
final class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
private var screen: CarScreen?
func templateApplicationScene(_ scene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController) {
screen = CarScreen(interface: interfaceController)
}
func templateApplicationScene(_ scene: CPTemplateApplicationScene,
didDisconnectInterfaceController interfaceController: CPInterfaceController) {
screen = nil
}
}
@MainActor
final class CarScreen: NSObject, CPTabBarTemplateDelegate {
private let interface: CPInterfaceController
private let inProgress = CPListTemplate(title: "In Progress", sections: [])
private let unread = CPListTemplate(title: "Unread", sections: [])
private let shows = CPListTemplate(title: "Shows", sections: [])
private var feeds: [String: CarFeed] = [:]
private var art: [String: UIImage] = [:]
init(interface: CPInterfaceController) {
self.interface = interface
super.init()
inProgress.tabImage = UIImage(systemName: "play.circle")
unread.tabImage = UIImage(systemName: "tray.full")
shows.tabImage = UIImage(systemName: "square.stack")
let tabs = CPTabBarTemplate(templates: [inProgress, unread, shows])
tabs.delegate = self
interface.setRootTemplate(tabs, animated: false, completion: nil)
refresh(inProgress)
}
/// Each list again whenever it is shown: a drive is long, and what was unread at the start
/// has been heard by the end.
func tabBarTemplate(_ tabBarTemplate: CPTabBarTemplate, didSelect selectedTemplate: CPTemplate) {
refresh(selectedTemplate)
}
private func refresh(_ t: CPTemplate) {
Task {
await loadFeeds()
// More than the car shows, since items with no file to play are dropped.
switch t {
case inProgress: await episodes(in: inProgress, from: "/api/entries?filter=in_progress&limit=100",
empty: "Nothing started")
case unread: await episodes(in: unread, from: "/api/entries?filter=unread&limit=100",
empty: "Nothing unread")
case shows: showFeeds()
default: break
}
}
}
private func loadFeeds() async {
guard let data = await API.get("/api/feeds"),
let rows = try? JSONDecoder().decode([CarFeed].self, from: data) else { return }
feeds = Dictionary(rows.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
}
private func episodes(in list: CPListTemplate, from path: String, empty: String, oneFeed: Bool = false) async {
guard let data = await API.get(path), let page = try? JSONDecoder().decode(CarPage.self, from: data) else {
unreachable(list)
return
}
let kept = Set(Offline.shared.store.paths)
let playing = Playback.shared.current
let rows: [CPListItem] = page.entries.compactMap { e in
let feed = feeds[e.feed_id]
guard let item = e.item(feedTitle: feed?.name ?? e.feed_id, feedImage: feed?.image, kept: kept) else {
return nil
}
// In a show's own list its name is the title above; across shows it is the detail.
let detail = (oneFeed ? [e.left] : [item.feedTitle, e.left]).compactMap { $0 }.joined(separator: " · ")
let row = CPListItem(text: item.title, detailText: detail.isEmpty ? nil : detail)
row.isPlaying = playing?.guid == e.guid && playing?.feedId == e.feed_id
row.handler = { [weak self] _, done in
self?.play(item, from: e.position)
done()
}
picture(item.artwork, for: row)
return row
}
list.emptyViewTitleVariants = [empty]
list.emptyViewSubtitleVariants = []
list.updateSections([CPListSection(items: Array(rows.prefix(CPListTemplate.maximumItemCount)))])
}
/// Alphabetical, which a driver can find a show in without reading the whole list. Only
/// shows with files downloaded (#443): a blog has nothing a car can play, and an OPML
/// subscription's own row holds no items, its feeds do.
private func showFeeds() {
guard !feeds.isEmpty else {
unreachable(shows)
return
}
let sorted = feeds.values.filter { ($0.downloaded ?? 0) > 0 }
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
let rows: [CPListItem] = sorted.map { f in
let n = f.unread ?? 0
let row = CPListItem(text: f.name, detailText: n > 0 ? "\(n) unread" : nil)
row.accessoryType = .disclosureIndicator
row.handler = { [weak self] _, done in
self?.open(f)
done()
}
picture(f.image, for: row)
return row
}
shows.emptyViewTitleVariants = ["No shows"]
shows.emptyViewSubtitleVariants = []
shows.updateSections([CPListSection(items: Array(rows.prefix(CPListTemplate.maximumItemCount)))])
}
private func open(_ f: CarFeed) {
let list = CPListTemplate(title: f.name, sections: [])
interface.pushTemplate(list, animated: true, completion: nil)
let id = f.id.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? f.id
Task { await episodes(in: list, from: "/api/feeds/\(id)/entries?limit=100", empty: "No episodes", oneFeed: true) }
}
/// Signed out, or no connection. Sign-in is the page's, which a car cannot show.
private func unreachable(_ list: CPListTemplate) {
list.emptyViewTitleVariants = ["iPX can't reach your server"]
list.emptyViewSubtitleVariants = ["Open iPX on your phone and sign in."]
list.updateSections([])
}
private func play(_ item: Playback.Item, from position: Double) {
Playback.shared.playChosen(item, startAt: position > 5 ? position : nil)
interface.pushTemplate(CPNowPlayingTemplate.shared, animated: true, completion: nil)
}
/// A show's or an episode's picture, fetched through ipx's `/api/art` as the page's are, and
/// kept for the drive: the lists are made again on every visit.
private func picture(_ src: String?, for row: CPListItem) {
guard let src else { return }
if let image = art[src] {
row.setImage(image)
return
}
let path = src.lowercased().hasPrefix("http")
? "/api/art?u=" + (src.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? src)
: src
Task {
guard let data = await API.get(path), let image = UIImage(data: data) else { return }
let size = CPListItem.maximumImageSize
let small = UIGraphicsImageRenderer(size: size).image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
art[src] = small
row.setImage(small)
}
}
}