web/src/native.ts (#45) has handed playback to a host since September, and there was no host in this repo: the earlier app, rays/ipodderx-app, grew native lists beside the page and is left behind. This one is the page and a player: a WKWebView of ipx-web, AVPlayer behind the bridge with the audio session to play on locked, now playing and the remote commands (15 back, 30 forward, scrubbing), and positions and read written from the player's own clock, with DeadAir's copy named so the server reads the position on its clock. Built with XcodeGen on a Mac; nothing here compiles the Swift. Its icon is web/logo.svg at 1024. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
75 lines
3.4 KiB
Swift
75 lines
3.4 KiB
Swift
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
|
|
|
|
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]) }
|
|
}
|
|
|
|
// 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)) }
|
|
case "load":
|
|
guard let path = m["url"] as? String, let url = Server.resolve(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()
|
|
default: break
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|