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? } 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 that is not a video, which a car would only play the sound of /// with nothing to say so. var audio: Enclosure? { enclosures.first { !($0.mime ?? "").lowercased().hasPrefix("video/") } } /// 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 { 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) -> 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? 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 items: an OPML subscription's own row holds none, its feeds do. private func showFeeds() { guard !feeds.isEmpty else { unreachable(shows) return } let sorted = feeds.values.filter { ($0.entries ?? 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) } } }