#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
168 lines
8.3 KiB
Swift
168 lines
8.3 KiB
Swift
import AVKit
|
|
import Foundation
|
|
import WebKit
|
|
|
|
/// Between the page and the phone's player.
|
|
///
|
|
/// The page's half is web/src/native.ts. It replaces the playback surface of the page's media
|
|
/// element with one that posts here, so player.ts (the player bar, the row buttons, the keys)
|
|
/// carries on unchanged while this side plays. tests/native-bridge.ts holds the page's half to
|
|
/// the messages; ios/README.md lists them.
|
|
@MainActor
|
|
final class Bridge: NSObject, WKScriptMessageHandler {
|
|
static let name = "ipx"
|
|
|
|
private weak var webView: WKWebView?
|
|
private let playback: Playback
|
|
private let impact = UIImpactFeedbackGenerator(style: .light)
|
|
private let notice = UINotificationFeedbackGenerator()
|
|
/// The system's AirPlay picker, which has no API to open it but its own button: kept on the
|
|
/// web view, invisible, and pressed when the page's button is.
|
|
private lazy var routePicker: AVRoutePickerView = {
|
|
let v = AVRoutePickerView()
|
|
v.alpha = 0
|
|
v.isUserInteractionEnabled = false
|
|
v.prioritizesVideoDevices = false
|
|
webView?.addSubview(v)
|
|
return v
|
|
}()
|
|
|
|
init(webView: WKWebView, playback: Playback) {
|
|
self.webView = webView
|
|
self.playback = playback
|
|
super.init()
|
|
playback.onTime = { [weak self] cur, dur in
|
|
// Built up rather than `dur as Any`: a nil cast to Any is an Any holding nil, which
|
|
// JSONSerialization refuses, and every tick before the length was known would drop.
|
|
var m: [String: Any] = ["t": "time", "cur": cur]
|
|
if let dur { m["dur"] = dur }
|
|
self?.send(m)
|
|
}
|
|
playback.onMeta = { [weak self] dur in self?.send(["t": "meta", "dur": dur]) }
|
|
playback.onState = { [weak self] playing in self?.send(["t": "state", "playing": playing]) }
|
|
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.onChosen = { [weak self] in self?.sendNowPlaying() }
|
|
// Again on coming forward: a page frozen while the car chose may have missed it (#442).
|
|
NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil,
|
|
queue: .main) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.sendNowPlaying() }
|
|
}
|
|
Push.onToken = { [weak self] _ in self?.sendPush() }
|
|
Offline.shared.onChange = { [weak self] in self?.sendKept() }
|
|
Offline.shared.onFail = { [weak self] path, msg in
|
|
self?.send(["t": "keepFailed", "url": path, "message": msg])
|
|
}
|
|
}
|
|
|
|
/// What the phone holds, and is fetching, by the paths the page keeps them under (#425).
|
|
private func sendKept() {
|
|
send(["t": "kept", "urls": Offline.shared.store.paths,
|
|
"keeping": Offline.shared.downloading.sorted()])
|
|
}
|
|
|
|
/// What is loaded, for the page's player bar to take up (#442): the car's pick, or an episode
|
|
/// playing when the page loads, as when the app was opened from CarPlay. Without it the sound
|
|
/// went on with no bar on the phone to pause it.
|
|
private func sendNowPlaying() {
|
|
guard let item = playback.current else { return }
|
|
var m: [String: Any] = ["t": "nowPlaying", "feedId": item.feedId, "guid": item.guid, "enc": item.enc,
|
|
"title": item.title, "feedTitle": item.feedTitle,
|
|
"playing": playback.wantsToPlay, "cur": playback.currentTime]
|
|
if let a = item.artwork { m["artwork"] = a }
|
|
if let d = playback.duration ?? item.duration.map(Double.init) { m["dur"] = d }
|
|
send(m)
|
|
}
|
|
|
|
/// For the page to register with ipx (#433), at ready and whenever Apple gives a new one.
|
|
private func sendPush() {
|
|
guard let token = Push.token else { return }
|
|
send(["t": "push", "token": token, "sandbox": Push.sandbox])
|
|
}
|
|
|
|
// MARK: - page to host
|
|
|
|
func userContentController(_ controller: WKUserContentController,
|
|
didReceive message: WKScriptMessage) {
|
|
guard let m = message.body as? [String: Any], let t = m["t"] as? String else { return }
|
|
switch t {
|
|
case "ready":
|
|
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
|
|
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) }
|
|
// What this build can do, so a newer page offers nothing it would ask for in vain.
|
|
send(["t": "host", "can": ["sleep", "route"]])
|
|
// A page reloaded while the timer runs is told how long is left.
|
|
if let at = playback.sleepAt { send(["t": "sleep", "left": max(0, at.timeIntervalSinceNow)]) }
|
|
sendKept()
|
|
sendPush()
|
|
sendNowPlaying()
|
|
case "load":
|
|
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
|
|
let enc = m["enc"] as? Int,
|
|
let feedId = m["feedId"] as? String, let guid = m["guid"] as? String else { return }
|
|
playback.load(.init(
|
|
url: url, enc: enc, feedId: feedId, guid: guid,
|
|
title: m["title"] as? String ?? "",
|
|
feedTitle: m["feedTitle"] as? String ?? "",
|
|
artwork: m["artwork"] as? String,
|
|
duration: m["duration"] as? Int))
|
|
case "play": playback.play()
|
|
case "pause": playback.pause()
|
|
case "stop": playback.stop()
|
|
case "seek": if let to = m["to"] as? Double { playback.seek(to: to) }
|
|
case "rate": if let v = m["v"] as? Double { playback.setRate(Float(v)) }
|
|
case "volume": if let v = m["v"] as? Double { playback.setVolume(Float(v)) }
|
|
// The page sends the path it would have written to, not a time: ours is the clock that
|
|
// is not stale.
|
|
case "position": playback.savePosition()
|
|
case "keep": if let url = m["url"] as? String { Offline.shared.keep(url) }
|
|
case "unkeep": if let url = m["url"] as? String { Offline.shared.remove(url) }
|
|
case "clearKept": Offline.shared.removeAll()
|
|
case "sleep": playback.sleep(after: m["secs"] as? Double ?? 0)
|
|
case "route": showRoutes(m)
|
|
case "haptic": haptic(m["kind"] as? String)
|
|
case "unread": if let n = m["n"] as? Int { Badge.set(n) }
|
|
default: break
|
|
}
|
|
}
|
|
|
|
/// Opens the picker over the page's button: on an iPhone a sheet, on an iPad a popover
|
|
/// pointing at it. The page sends where the button is, in the web view's points.
|
|
private func showRoutes(_ m: [String: Any]) {
|
|
let n = { (k: String) in m[k] as? Double ?? 0 }
|
|
routePicker.frame = CGRect(x: n("x"), y: n("y"), width: max(1, n("w")), height: max(1, n("h")))
|
|
// Its button is made on layout, and opens the picker on its primary action; a version
|
|
// with nothing on that, as older ones may have, gets the touch that used to do it.
|
|
routePicker.layoutIfNeeded()
|
|
guard let button = Self.controls(in: routePicker).first else { return }
|
|
let primary = button.allTargets.contains {
|
|
button.actions(forTarget: $0, forControlEvent: .primaryActionTriggered)?.isEmpty == false
|
|
}
|
|
button.sendActions(for: primary ? .primaryActionTriggered : .touchUpInside)
|
|
}
|
|
|
|
/// The page's presses felt (#430): light for a tap, the system's success and warning for
|
|
/// something done and something gone wrong, as Mail and Messages use them.
|
|
private func haptic(_ kind: String?) {
|
|
switch kind {
|
|
case "done": notice.notificationOccurred(.success)
|
|
case "warn": notice.notificationOccurred(.warning)
|
|
default: impact.impactOccurred()
|
|
}
|
|
}
|
|
|
|
private static func controls(in v: UIView) -> [UIControl] {
|
|
v.subviews.flatMap { ($0 as? UIControl).map { [$0] } ?? controls(in: $0) }
|
|
}
|
|
|
|
// MARK: - host to page
|
|
|
|
private func send(_ payload: [String: Any]) {
|
|
guard let webView, let data = try? JSONSerialization.data(withJSONObject: payload),
|
|
let json = String(data: data, encoding: .utf8) else { return }
|
|
// A frozen or reloading page misses a tick; the next one puts it right.
|
|
webView.evaluateJavaScript("window.ipxNative && window.ipxNative.on(\(json))", completionHandler: nil)
|
|
}
|
|
}
|