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