Files
ipx/ios/Sources/Bridge.swift
Ray Slakinski 2e031763fa Player: a sleep timer, and AirPlay in the app (#429)
The sleep timer pauses after 5 minutes to an hour, fading out over the
last ten seconds. In the iOS app the phone's player keeps the time:
with the screen locked the web view is frozen, and a timer in the page
would not fire until the app was opened again. A browser plays on in a
background tab, so there the page keeps it.

The app says at ready what it does itself ({t:"host", can}), so a page
deployed ahead of the app falls back to its own timer and shows no
AirPlay button an older app would ignore. This deploy order problem
came up with #425.

AirPlay opens AVRoutePickerView by pressing its button from code; there
is no API to present it. On iOS 27 the picker opens on the button's
primary action, not touch-up, so that is what is sent, with touch-up
for versions that have nothing on it. The simulator has no AirPlay
devices and never showed the picker, so this is unverified until it
runs on a phone.

On a phone the player's one line left the title two letters wide once
the two controls were added, so the title takes the top line and the
controls flank play below it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:54:08 -05:00

126 lines
6.1 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
/// 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)
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)
}
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)
}
}