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:
Ray Slakinski
2026-11-09 08:10:29 -05:00
parent 711b0395f1
commit 2bb7ed2109
12 changed files with 354 additions and 18 deletions

View File

@@ -13,6 +13,7 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.
- A sleep timer in the player, kept by the phone, so it pauses on time with the screen locked, fading out over the last ten seconds; and an AirPlay button beside it, for another speaker or a TV (#429). - A sleep timer in the player, kept by the phone, so it pauses on time with the screen locked, fading out over the last ten seconds; and an AirPlay button beside it, for another speaker or a TV (#429).
- iPX opens with no connection, showing the lists it last had, as it does in a browser: the page's service worker now runs in the app. This is for `ipodderx.sdf1.net` only, and links to other sites open in Safari (#435). - iPX opens with no connection, showing the lists it last had, as it does in a browser: the page's service worker now runs in the app. This is for `ipodderx.sdf1.net` only, and links to other sites open in Safari (#435).
- Siri and Shortcuts: "Resume iPX", "Pause iPX", "Skip forward in iPX", "Skip back in iPX", "Set a sleep timer in iPX" and "How many unread in iPX", with nothing to set up, and each an action in Shortcuts. Resume works with the app closed, carrying on with the last episode from where you left off (#427). - Siri and Shortcuts: "Resume iPX", "Pause iPX", "Skip forward in iPX", "Skip back in iPX", "Set a sleep timer in iPX" and "How many unread in iPX", with nothing to set up, and each an action in Shortcuts. Resume works with the app closed, carrying on with the last episode from where you left off (#427).
- CarPlay: In Progress, Unread and Shows on the car's screen, an episode playing from where you left off and saving its place as it does on the phone, and the car's Now Playing for the rest (#420).
- A widget for the home screen and lock screen: the unread count, and the episode you were on with a button to resume or pause it (#428). - A widget for the home screen and lock screen: the unread count, and the episode you were on with a button to resume or pause it (#428).
- The icon's count stays right with the app closed: ipx sends it to the phone when it changes, once its `[push]` is set up, in place of waiting for iOS's background refresh (#433). - The icon's count stays right with the app closed: ipx sends it to the phone when it changes, once its `[push]` is set up, in place of waiting for iOS's background refresh (#433).
- Presses are felt: a light tap on play, pause, flag and pull to refresh, a success on marking read, a warning with an error (#430). - Presses are felt: a light tap on play, pause, flag and pull to refresh, a success on marking read, a warning with an error (#430).

View File

@@ -19,7 +19,8 @@ from `project.yml`, so `.xcodeproj` is not in git: edit the yml. Nothing on Towe
A free personal team works, but a build on it expires after seven days and is reinstalled from A free personal team works, but a build on it expires after seven days and is reinstalled from
Xcode. A paid account ($99 a year) makes that a year and adds TestFlight. Playing with the screen Xcode. A paid account ($99 a year) makes that a year and adds TestFlight. Playing with the screen
locked is a plist key (`UIBackgroundModes: audio`), not an entitlement, so it works on either. locked is a plist key (`UIBackgroundModes: audio`), not an entitlement, so it works on either.
CarPlay's own dashboard app does not: Apple grants `com.apple.developer.carplay-audio` on request. CarPlay does not: Apple grants `com.apple.developer.carplay-audio` on request (granted for team
8743L8D729 on 2026-11-09), and the App ID needs it ticked under Additional Capabilities.
## Which server ## Which server
@@ -133,6 +134,7 @@ App to page, as `window.ipxNative.on(...)`:
| `{t:"push", token, sandbox}` | at `ready` and when Apple gives a new one: the phone's push token, for the page to register with ipx (`POST /api/push/devices`) (#433) | | `{t:"push", token, sandbox}` | at `ready` and when Apple gives a new one: the phone's push token, for the page to register with ipx (`POST /api/push/devices`) (#433) |
| `{t:"sleep", left}` | at `ready`, while the timer runs: seconds left, for a page that reloaded | | `{t:"sleep", left}` | at `ready`, while the timer runs: seconds left, for a page that reloaded |
| `{t:"slept"}` | the timer ran out and playback paused | | `{t:"slept"}` | the timer ran out and playback paused |
| `{t:"replaced"}` | the car chose another episode; the page's player bar closes without saving |
The app writes the position (every ten seconds and on pause) and marks an item read at 90%, from The app writes the position (every ten seconds and on pause) and marks an item read at 90%, from
its own clock, so both happen while the page is frozen. Video stays in the page. its own clock, so both happen while the page is frozen. Video stays in the page.
@@ -141,5 +143,7 @@ its own clock, so both happen while the page is frozen. Video stays in the page.
* Without the player token the cookie is the only credential: when Cloudflare Access's expires * Without the player token the cookie is the only credential: when Cloudflare Access's expires
mid-drive, playback stops with "Sign in again", and opening the app signs in. mid-drive, playback stops with "Sign in again", and opening the app signs in.
* CarPlay's dashboard app needs Apple's entitlement first; then it is a second scene over the same * CarPlay (`CarScreen.swift`) reads ipx with the player's credentials and nothing else: launched
`Playback`. by the car with the phone's app never opened since a restart, it has only cookies saved to disk,
and without the player token a lapsed Access cookie shows "iPX can't reach your server". Its
lists need a connection; a kept episode plays without one once it is picked.

23
ios/Sources/API.swift Normal file
View 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
}
}

View File

@@ -71,17 +71,8 @@ enum Badge {
/// All Subscriptions' unread count from ipx, or nil when it cannot be had. /// All Subscriptions' unread count from ipx, or nil when it cannot be had.
static func fetch() async -> Int? { static func fetch() async -> Int? {
guard let url = Server.resolveForPlayer("/api/feeds") else { return nil } guard let data = await API.get("/api/feeds") else { return nil }
var req = URLRequest(url: url) return unread(inAll: data)
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
} }
// MARK: - while the app is closed // MARK: - while the app is closed

View File

@@ -43,6 +43,7 @@ final class Bridge: NSObject, WKScriptMessageHandler {
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) } playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) } playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) }
playback.onSlept = { [weak self] in self?.send(["t": "slept"]) } 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() } Push.onToken = { [weak self] _ in self?.sendPush() }
Offline.shared.onChange = { [weak self] in self?.sendKept() } Offline.shared.onChange = { [weak self] in self?.sendKept() }
Offline.shared.onFail = { [weak self] path, msg in Offline.shared.onFail = { [weak self] path, msg in

233
ios/Sources/CarScreen.swift Normal file
View 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)
}
}
}

View File

@@ -29,6 +29,8 @@ final class Playback: NSObject {
var onError: ((String) -> Void)? var onError: ((String) -> Void)?
/// The sleep timer ran out and playback paused. /// The sleep timer ran out and playback paused.
var onSlept: (() -> Void)? var onSlept: (() -> Void)?
/// Something the page did not choose is playing now: the car's pick (#420).
var onReplaced: (() -> Void)?
private let session: URLSession = { private let session: URLSession = {
let cfg = URLSessionConfiguration.default let cfg = URLSessionConfiguration.default
@@ -53,6 +55,8 @@ final class Playback: NSObject {
private(set) var sleepAt: Date? private(set) var sleepAt: Date?
var isPlaying: Bool { player?.timeControlStatus == .playing } 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 currentTime: Double { player?.currentTime().seconds ?? 0 }
var duration: Double? { var duration: Double? {
guard let d = player?.currentItem?.duration.seconds, d.isFinite, d > 0 else { return nil } guard let d = player?.currentItem?.duration.seconds, d.isFinite, d > 0 else { return nil }
@@ -163,6 +167,19 @@ final class Playback: NSObject {
return true 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() { func pause() {
player?.pause() player?.pause()
onState?(false) onState?(false)

36
ios/Tests/CarTests.swift Normal file
View File

@@ -0,0 +1,36 @@
import XCTest
@testable import iPX
final class CarTests: XCTestCase {
private func episode(_ json: String) throws -> CarEpisode {
try JSONDecoder().decode(CarEpisode.self, from: Data(json.utf8))
}
func testAnItemReadsAsTheAPISendsIt() throws {
let e = try episode("""
{"guid":"g","feed_id":"f","title":null,"link":null,"image":null,"duration":1800,"position":600,
"read":false,"flagged":false,"enclosures":[
{"id":7,"feed_id":"f","guid":"g","url":"https://x/v.mp4","mime":"video/mp4","state":"done"},
{"id":8,"feed_id":"f","guid":"g","url":"https://x/Show%20Ep%201.mp3","mime":"audio/mpeg","state":"done"}]}
""")
XCTAssertEqual(e.audio?.id, 8, "a video is passed over for the sound")
XCTAssertEqual(e.name, "Show Ep 1", "untitled is named by its file")
XCTAssertEqual(e.left, "20 min left")
}
func testNothingToPlayIsNotListed() throws {
let e = try episode(#"{"guid":"g","feed_id":"f","title":"A post","position":0,"enclosures":[]}"#)
XCTAssertNil(e.audio)
XCTAssertNil(e.left, "not started")
XCTAssertNil(e.item(feedTitle: "F", feedImage: nil, kept: []))
}
func testTheFileIsTheOneThePageWouldPlay() {
let plain = CarEpisode.Enclosure(id: 4, url: "u", mime: nil, deadair: nil)
let trimmed = CarEpisode.Enclosure(id: 5, url: "u", mime: nil, deadair: 90)
XCTAssertEqual(CarEpisode.path(of: plain, kept: []), "/media/4")
XCTAssertEqual(CarEpisode.path(of: trimmed, kept: []), "/media/5/deadair.m3u8")
XCTAssertEqual(CarEpisode.path(of: trimmed, kept: ["/media/5/deadair"]), "/media/5/deadair",
"the copy on the phone, connection or not")
}
}

View File

@@ -28,9 +28,8 @@ targets:
properties: properties:
CFBundleDisplayName: iPX CFBundleDisplayName: iPX
CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: "12" CFBundleVersion: "13"
# Playing on with the screen locked. A plist key, not a signed entitlement, so it works on # Playing on with the screen locked. A plist key, not a signed entitlement.
# a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant.
UIBackgroundModes: [audio, fetch, remote-notification] UIBackgroundModes: [audio, fetch, remote-notification]
# The unread badge's refresh while the app is closed (#421). # The unread badge's refresh while the app is closed (#421).
BGTaskSchedulerPermittedIdentifiers: [net.sdf1.ipx.refresh] BGTaskSchedulerPermittedIdentifiers: [net.sdf1.ipx.refresh]
@@ -43,6 +42,11 @@ targets:
UIWindowSceneSessionRoleApplication: UIWindowSceneSessionRoleApplication:
- UISceneConfigurationName: Default Configuration - UISceneConfigurationName: Default Configuration
UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).SceneDelegate UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).SceneDelegate
# The car's screen (#420), a scene of its own beside the phone's.
CPTemplateApplicationSceneSessionRoleApplication:
- UISceneConfigurationName: CarPlay
UISceneClassName: CPTemplateApplicationScene
UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate
UISupportedInterfaceOrientations: UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationPortraitUpsideDown
@@ -66,6 +70,8 @@ targets:
aps-environment: development aps-environment: development
# Shared with the widget (#428), which reads what the app last knew from it. # Shared with the widget (#428), which reads what the app last knew from it.
com.apple.security.application-groups: [group.net.sdf1.ipx] com.apple.security.application-groups: [group.net.sdf1.ipx]
# Granted by Apple on request (2026-11-09); the App ID has it under Additional Capabilities.
com.apple.developer.carplay-audio: true
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx
@@ -97,7 +103,7 @@ targets:
properties: properties:
CFBundleDisplayName: iPX CFBundleDisplayName: iPX
CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: "12" CFBundleVersion: "13"
NSExtension: NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension NSExtensionPointIdentifier: com.apple.widgetkit-extension
entitlements: entitlements:

View File

@@ -193,5 +193,18 @@ await vm.runInContext('forgetPush()', ctx);
ok(fetched.some(f => f.startsWith(`DELETE /api/push/devices/${'ab'.repeat(32)}`)), 'signing out left ipx counting for this phone'); ok(fetched.some(f => f.startsWith(`DELETE /api/push/devices/${'ab'.repeat(32)}`)), 'signing out left ipx counting for this phone');
if (failed) { console.error(`\n${failed} failed`); process.exit(1); } if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
// CarPlay (#420): the car picks another episode, and the page's bar lets go of the old one.
vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
body.classList.toggle('has-video', false);
audio.src = '/media/42';
ctx.window.ipxNative.on({ t: 'state', playing: true });
since();
ctx.window.ipxNative.on({ t: 'replaced' });
ok(vm.runInContext('player.guid', ctx) === null, "the bar still shows the episode the car replaced");
ok(audio.paused === true, 'the page still thinks its episode plays');
audio.removeAttribute('src');
ok(!last('stop'), "closing the page's player stopped what the car chose");
ok(!posted.some(m => m.t === 'position'), 'the old episode saved its place by the new one\'s clock');
console.log('OK: native-bridge: the host takes playback, keeping, the sleep timer and haptics, and the page follows it'); console.log('OK: native-bridge: the host takes playback, keeping, the sleep timer and haptics, and the page follows it');
process.exit(0); process.exit(0);

View File

@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- In the iOS app, the page registers the phone with ipx for its icon's count, and signing out removes it (#433). - In the iOS app, the page registers the phone with ipx for its icon's count, and signing out removes it (#433).
- In the iOS app, the player bar closes when CarPlay starts a different episode, instead of going on as the old one (#420).
- In the iOS app, the icon's unread count follows the page's: reading and new items change it at once, not the next time the app is opened (#421). - In the iOS app, the icon's unread count follows the page's: reading and new items change it at once, not the next time the app is opened (#421).
- In the iOS app, presses are felt: play, pause, flag, pull to refresh, marking read and errors each give the phone's haptic for it (#430). - In the iOS app, presses are felt: play, pause, flag, pull to refresh, marking read and errors each give the phone's haptic for it (#430).
- A sleep timer in the player: 5 minutes to an hour, then playback fades out and pauses. In the iOS app the phone keeps the time, so it works with the screen locked; the app and Safari also get an AirPlay button. On a phone the title has the player's top line to itself, with the controls either side of play below it (#429). - A sleep timer in the player: 5 minutes to an hour, then playback fades out and pauses. In the iOS app the phone keeps the time, so it works with the screen locked; the app and Safari also get an AirPlay button. On a phone the title has the player's top line to itself, with the controls either side of play below it (#429).

View File

@@ -200,6 +200,16 @@ function installNativePlayback(){
fire('pause'); fire('pause');
fire('ended'); fire('ended');
break; break;
case 'replaced':
// The car chose another episode (#420). The bar would go on as this one, saving its
// place and marking it read by the other's clock, so it closes. No pause event: that
// saves, and N.on is off, so it would write the page's stale time to ipx itself.
N.on = false; N.paused = true;
player.guid = null;
$('#player').classList.remove('on');
renderEntries();
syncListening();
break;
case 'error': case 'error':
N.paused = true; N.paused = true;
fire('pause'); fire('pause');