Files
ipx/ios/Sources/Bridge.swift
Ray Slakinski cf0dad7380 iOS: presses are felt (#430)
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>
2026-11-08 16:57:51 -05:00

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)
}
}