Files
ipx/ios/Sources/Bridge.swift
Ray Slakinski 171c77475a iOS: the app's settings in Settings, not behind a shake; iPhone upright (#445)
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>
2026-11-13 08:39:54 -05:00

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