import Foundation import WebKit /// Between the page and the host's player. /// /// The page's half is `web/src/native.ts` in ipodderx-rs. It replaces the playback surface of the /// page's media element with one that posts here, so everything in `player.ts` -- the player bar, /// the row buttons, the keyboard shortcuts -- carries on unchanged while the host does the /// playing. Messages are documented in this repo's README. final class Bridge: NSObject, WKScriptMessageHandler { static let handlerName = "ipx" private weak var webView: WKWebView? private let playback: Playback /// Nil until the page says hello. The app uses it to tell a server too old to carry the /// bridge from one that is fine, instead of sitting silent. private(set) var pageVersion: Int? var onReady: ((Int) -> Void)? init(webView: WKWebView, playback: Playback) { self.webView = webView self.playback = playback super.init() wire() } private func wire() { playback.onTime = { [weak self] cur, dur in // Built up rather than `dur as Any`: a nil Optional cast to Any is not nil, it is an // Any wrapping nil, which JSONSerialization refuses -- so every tick before the length // was known would have been dropped without a word. 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": let v = m["version"] as? Int ?? 0 pageVersion = v if let r = m["rate"] as? Double { playback.setRate(Float(r)) } if let v = m["volume"] as? Double { playback.setVolume(Float(v)) } onReady?(v) case "load": guard let path = m["url"] as? String, let url = ServerSettings.url(path), let feedId = m["feedId"] as? String, let guid = m["guid"] as? String else { return } playback.load(.init( url: url, feedId: feedId, guid: guid, title: m["title"] as? String ?? "", feedTitle: m["feedTitle"] as? String ?? "", artwork: m["artwork"] as? String, position: m["position"] as? Int ?? 0, duration: m["duration"] as? Int)) case "play": playback.play() case "pause": playback.pause() 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)) } case "stop": playback.stop() case "position": playback.savePosition(path: m["url"] as? String) default: break } } // MARK: - host to page private func send(_ payload: [String: Any]) { guard let webView, JSONSerialization.isValidJSONObject(payload), let data = try? JSONSerialization.data(withJSONObject: payload), let json = String(data: data, encoding: .utf8) else { NSLog("ipx: dropping an unencodable message: %@", String(describing: payload)) return } let js = "window.ipxNative && window.ipxNative.on(\(json))" DispatchQueue.main.async { webView.evaluateJavaScript(js) { _, err in // A frozen or reloading web view is not a problem worth a log line every half // second: the host is the one playing, and it carries on either way. if let err = err as NSError?, err.code != WKError.javaScriptExceptionOccurred.rawValue { return } } } } }