Files
ipx/ios/Sources/WebViewController.swift
rays a581e3fc88 An iOS app: the page in a web view, with the phone's own player (#418)
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
2026-11-07 21:50:43 +00:00

113 lines
5.1 KiB
Swift

import UIKit
import WebKit
/// The app, very nearly: ipx's own page, full screen, with the phone's player behind it.
final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
private var webView: WKWebView!
private var cookies: CookieBridge!
// Lazy, so it is made in viewDidLoad, on the main actor it belongs to.
private lazy var playback = Playback()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
let cfg = WKWebViewConfiguration()
// The persistent store, so a sign-in through Cloudflare Access survives a relaunch, and
// there are cookies for the player to use at all.
cfg.websiteDataStore = .default()
cfg.allowsInlineMediaPlayback = true
// Video still plays in the page, and a tap on play is already the user's action.
cfg.mediaTypesRequiringUserActionForPlayback = []
webView = WKWebView(frame: view.bounds, configuration: cfg)
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
webView.navigationDelegate = self
webView.uiDelegate = self
// The page is viewport-fit=cover and pads itself by the safe area, as it does on the
// home screen; letting the scroll view inset it too would pad it twice.
webView.scrollView.contentInsetAdjustmentBehavior = .never
webView.isOpaque = false
webView.backgroundColor = .black
view.addSubview(webView)
cookies = CookieBridge(store: cfg.websiteDataStore.httpCookieStore)
cfg.userContentController.add(Bridge(webView: webView, playback: playback), name: Bridge.name)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if webView.url == nil {
if let url = Server.url { webView.load(URLRequest(url: url)) } else { askForServer() }
}
}
// MARK: - which server
/// Shake to change it. A setting touched about once, so it takes no room on the screen.
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
if motion == .motionShake { askForServer() }
}
private func askForServer(_ problem: String? = nil) {
guard presentedViewController == nil else { return }
let alert = UIAlertController(
title: "Your iPX server",
message: problem ?? "The address you open iPX at. Shake the phone to change it later.",
preferredStyle: .alert)
alert.addTextField { f in
f.text = Server.url?.absoluteString ?? Server.suggested
f.keyboardType = .URL
f.autocapitalizationType = .none
f.autocorrectionType = .no
f.clearButtonMode = .whileEditing
}
if Server.url != nil { alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) }
alert.addAction(UIAlertAction(title: "Open", style: .default) { [weak self, weak alert] _ in
guard let self else { return }
let text = alert?.textFields?.first?.text ?? ""
guard let url = Server.normalise(text) else {
self.askForServer("That is not an address the app can open. Plain http only works "
+ "on your own network, such as 192.168.1.130:8099.")
return
}
Server.url = url
self.playback.stop()
self.webView.load(URLRequest(url: url))
})
present(alert, animated: true)
}
// MARK: - the page
/// Sign-in leaves the server's address (Cloudflare Access, its identity provider) and must
/// stay in here to leave its cookies behind; only what the page opens in a new tab, an
/// item's own link, goes to Safari.
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures) -> WKWebView? {
if let url = navigationAction.request.url { UIApplication.shared.open(url) }
return nil
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
withError error: Error) {
let host = Server.url?.host ?? "the server"
let alert = UIAlertController(title: "Cannot reach \(host)",
message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Try Again", style: .default) { [weak self] _ in
if let url = Server.url { self?.webView.load(URLRequest(url: url)) }
})
alert.addAction(UIAlertAction(title: "Change Server", style: .default) { [weak self] _ in
self?.askForServer()
})
if presentedViewController == nil { present(alert, animated: true) }
}
/// WebKit's content process can be killed in the background to free memory, leaving a blank
/// page. The player is in this process and plays on; the page comes back where it was.
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
webView.reload()
}
}