Files
ipx/ios/Sources/Bridge.swift
Ray Slakinski ecc248307e iOS: episodes kept on the phone play with no connection (#425)
A WKWebView runs no service worker, so the page's Keep button (#412)
was not shown in the app and everything played from the server. Now the
page asks the app to keep the file (keep, unkeep, clearKept), and the
app answers with what it holds (kept, keepFailed) whether or not
anything plays. The app fetches with a background URLSession, so a
download goes on with the app closed, into Application Support (not
backed up; the files can be fetched again), and Playback plays from the
file when there is one. The DeadAir copy is kept under its own path, as
the browser keeps it. Places and finishes reached offline wait in an
outbox, newest per address, and go when the network is back.

Checked in the simulator against a local server: kept /media/42 and
/media/42/deadair, then with the server refusing /media, a fresh launch
played each from the phone and asked the server for nothing. The page
itself still needs a connection to open (#435).

Deploy the app (build 6) before the page: a new page in an old app
shows a Keep button nothing answers.

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

89 lines
4.1 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]) }
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)
}
}