The page asks the app for a haptic ({t:"haptic", kind}) and the app
plays the phone's own. A light tap for play, pause, flag and pull to
refresh; the system's success for marking read, one item or all; its
warning with an error toast. A browser has no host to ask, and an
older app ignores the message, so there is nothing to gate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
139 lines
6.7 KiB
Swift
139 lines
6.7 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"]) }
|
|
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()])
|
|
}
|
|
|
|
// 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()
|
|
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)
|
|
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)
|
|
}
|
|
}
|