The server address and the player token were set from alerts that opened on shaking the phone, which nobody finds. Settings now has a This device section in the app: the server, the player token and its address, and a Make button that creates an API token and hands it straight to the app's keychain. The app sends what it has at ready (appSettings) and takes setServer, setToken and clearToken; a new server rebuilds the web view as the alert did. The first-launch prompt and the Cannot reach alert stay native, since there is no page then. The iPhone is portrait only; the iPad keeps all four orientations, which Split View and Slide Over need (#434 checked the iPad's layout). Build 14. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
10 KiB
Swift
205 lines
10 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()])
|
|
}
|
|
|
|
/// A new server, checked, for the web view to load in place of this page (#445).
|
|
var onServer: ((URL) -> Void)?
|
|
|
|
/// The app's own settings for the page's Settings to show (#445); `saved` says a change took.
|
|
private func sendAppSettings(saved: Bool = false) {
|
|
send(["t": "appSettings", "server": Server.url?.absoluteString ?? Server.suggested,
|
|
"api": Server.apiURL?.absoluteString ?? Server.suggestedAPI,
|
|
"tokenSet": Credentials.apiToken != nil, "saved": saved])
|
|
}
|
|
|
|
/// The page's Settings, setting what an alert on shaking the phone used to (#445).
|
|
private func setToken(_ m: [String: Any]) {
|
|
guard let url = Server.normalise(m["api"] as? String ?? ""), url.scheme == "https" else {
|
|
send(["t": "appSettingsError",
|
|
"message": "The player address has to be https, such as \(Server.suggestedAPI)."])
|
|
return
|
|
}
|
|
Server.apiURL = url
|
|
if let token = m["token"] as? String, !token.isEmpty { Credentials.apiToken = token }
|
|
sendAppSettings(saved: true)
|
|
}
|
|
|
|
private func setServer(_ text: String) {
|
|
guard let url = Server.normalise(text) else {
|
|
send(["t": "appSettingsError", "message": "That is not an address the app can open. Plain http "
|
|
+ "only works on your own network, such as 192.168.1.130:8099."])
|
|
return
|
|
}
|
|
onServer?(url)
|
|
}
|
|
|
|
/// 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", "settings"]])
|
|
sendAppSettings()
|
|
// 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) }
|
|
case "setServer": setServer(m["url"] as? String ?? "")
|
|
case "setToken": setToken(m)
|
|
case "clearToken":
|
|
Credentials.apiToken = nil
|
|
sendAppSettings(saved: true)
|
|
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)
|
|
}
|
|
}
|