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