iOS: CarPlay (#420)
A CarPlay scene beside the phone's: In Progress, Unread and Shows as
CPListTemplates from ipx's API, read with the player's credentials, and
a pick played through the same Playback, so it resumes from the saved
place, saves as it goes and marks read at 90%. Now Playing is the
system's template over Playback's now-playing info.
When the car starts a different episode the page is sent
{t:"replaced"} and closes its player bar. Left open, the bar would have
gone on as the old episode while the host's clock belonged to the new
one, saving that time and marking the wrong item read.
The badge's fetch moves into API.get, which CarPlay shares. Build 13.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
23
ios/Sources/API.swift
Normal file
23
ios/Sources/API.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
/// Reading ipx without the page: the badge and CarPlay (#420), which has no web view. Signed in
|
||||
/// as the player is, by the credentials or else the cookies the web view left (CookieBridge).
|
||||
enum API {
|
||||
private static let session: URLSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.httpCookieStorage = .shared
|
||||
cfg.timeoutIntervalForRequest = 20
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
/// The body of a 200, or nil: not signed in, no server, no connection.
|
||||
static func get(_ path: String) async -> Data? {
|
||||
guard let url = Server.resolveForPlayer(path) else { return nil }
|
||||
var req = URLRequest(url: url)
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
|
||||
guard let (data, resp) = try? await session.data(for: req),
|
||||
(resp as? HTTPURLResponse)?.statusCode == 200 else { return nil }
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -71,17 +71,8 @@ enum Badge {
|
||||
|
||||
/// All Subscriptions' unread count from ipx, or nil when it cannot be had.
|
||||
static func fetch() async -> Int? {
|
||||
guard let url = Server.resolveForPlayer("/api/feeds") else { return nil }
|
||||
var req = URLRequest(url: url)
|
||||
req.timeoutInterval = 20
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.httpCookieStorage = .shared
|
||||
guard let (data, resp) = try? await URLSession(configuration: cfg).data(for: req),
|
||||
(resp as? HTTPURLResponse)?.statusCode == 200,
|
||||
let n = unread(inAll: data) else { return nil }
|
||||
return n
|
||||
guard let data = await API.get("/api/feeds") else { return nil }
|
||||
return unread(inAll: data)
|
||||
}
|
||||
|
||||
// MARK: - while the app is closed
|
||||
|
||||
@@ -43,6 +43,7 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
||||
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
|
||||
playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) }
|
||||
playback.onSlept = { [weak self] in self?.send(["t": "slept"]) }
|
||||
playback.onReplaced = { [weak self] in self?.send(["t": "replaced"]) }
|
||||
Push.onToken = { [weak self] _ in self?.sendPush() }
|
||||
Offline.shared.onChange = { [weak self] in self?.sendKept() }
|
||||
Offline.shared.onFail = { [weak self] path, msg in
|
||||
|
||||
233
ios/Sources/CarScreen.swift
Normal file
233
ios/Sources/CarScreen.swift
Normal file
@@ -0,0 +1,233 @@
|
||||
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>) -> 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?
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ final class Playback: NSObject {
|
||||
var onError: ((String) -> Void)?
|
||||
/// The sleep timer ran out and playback paused.
|
||||
var onSlept: (() -> Void)?
|
||||
/// Something the page did not choose is playing now: the car's pick (#420).
|
||||
var onReplaced: (() -> Void)?
|
||||
|
||||
private let session: URLSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
@@ -53,6 +55,8 @@ final class Playback: NSObject {
|
||||
private(set) var sleepAt: Date?
|
||||
|
||||
var isPlaying: Bool { player?.timeControlStatus == .playing }
|
||||
/// What is loaded, for the car's lists to mark.
|
||||
var current: Item? { item }
|
||||
var currentTime: Double { player?.currentTime().seconds ?? 0 }
|
||||
var duration: Double? {
|
||||
guard let d = player?.currentItem?.duration.seconds, d.isFinite, d > 0 else { return nil }
|
||||
@@ -163,6 +167,19 @@ final class Playback: NSObject {
|
||||
return true
|
||||
}
|
||||
|
||||
/// An episode chosen away from the page, in the car (#420): played from where it got to, and
|
||||
/// the page told, whose player bar would otherwise go on as the old one, saving its place and
|
||||
/// marking it read by this one's clock.
|
||||
func playChosen(_ item: Item, startAt: Double?) {
|
||||
if item == self.item {
|
||||
play()
|
||||
return
|
||||
}
|
||||
onReplaced?()
|
||||
load(item, startAt: startAt)
|
||||
play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
onState?(false)
|
||||
|
||||
Reference in New Issue
Block a user