iPodderX for iPhone, iPad and Mac

A shell around the ipodderx-rs web UI whose audio is played by the host
rather than the page, so it carries on with the screen locked and a car can
control it. The page's half of the bridge is web/src/native.ts in
ipodderx-rs (#45): it replaces the playback surface of the page's media
element with one that posts here, so player.ts is unchanged and the player
bar, the row buttons and the keyboard shortcuts work as they always did.

Playback, Library, Bridge and CookieBridge never touch a view, which is
what makes a CarPlay scene a later addition rather than a rewrite, and what
lets the same target build for Mac Catalyst with no conditional code:
AVAudioSession, MPNowPlayingInfoCenter and the remote commands all exist
there. The Mac gets media keys and Now Playing; it has no CarPlay and no
lock screen, so it is a convenience rather than the reason for any of this.

Authentication is the web view's. ipx decides who is asking by cookie --
ipx_session, and CF_Authorization from Cloudflare Access in front of the
tunnel -- and its auth layer was written so a plain <audio src> would work,
which is why the player needs no API of its own. CookieBridge keeps
HTTPCookieStorage in step with the web view's store and hands them to each
asset.

Named ipodderx-app, not -ios: native.ts already carries the branch for an
Android host, and the iOS project is one directory rather than the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 19:21:06 -04:00
commit 74a4ca6016
15 changed files with 1260 additions and 0 deletions

18
ios/Sources/App.swift Normal file
View File

@@ -0,0 +1,18 @@
import UIKit
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
true
}
/// Scenes, not a window on the delegate: iOS 27 refuses to run an app that has not adopted the
/// scene lifecycle at all. It is also what CarPlay needs -- a CarPlay app is a second scene
/// alongside this one, matched here by its role -- so there is nothing to undo later.
func application(_ application: UIApplication,
configurationForConnecting session: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
UISceneConfiguration(name: "Default Configuration", sessionRole: session.role)
}
}

93
ios/Sources/Bridge.swift Normal file
View File

@@ -0,0 +1,93 @@
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 }
}
}
}
}

View File

@@ -0,0 +1,46 @@
import Foundation
import WebKit
/// The web view signs in; everything else rides on what that left behind.
///
/// ipx authenticates a request by cookie -- `ipx_session` from its own sign-in form, and
/// `CF_Authorization` from Cloudflare Access in front of the tunnel. The auth layer was built that
/// way so a plain `<audio src>` would work, and it is why the native player needs no API of its
/// own. But `AVPlayer` and `URLSession` read `HTTPCookieStorage.shared`, and `WKWebView` keeps its
/// own store, so the two have to be kept in step.
final class CookieBridge: NSObject, WKHTTPCookieStoreObserver {
private let store: WKHTTPCookieStore
private(set) var cookies: [HTTPCookie] = []
init(store: WKHTTPCookieStore) {
self.store = store
super.init()
store.add(self)
sync()
}
func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { sync() }
/// Copy everything across. Cheap, and it runs only when the web view's store changes --
/// a sign-in, or Access handing out a fresh token.
func sync(then done: (() -> Void)? = nil) {
store.getAllCookies { [weak self] all in
guard let self else { return }
self.cookies = all
for c in all { HTTPCookieStorage.shared.setCookie(c) }
done?()
}
}
/// The cookies for one request, as `AVURLAsset` wants them.
func cookies(for url: URL) -> [HTTPCookie] {
cookies.filter { c in
guard let host = url.host else { return false }
let domain = c.domain.hasPrefix(".") ? String(c.domain.dropFirst()) : c.domain
guard host == domain || host.hasSuffix("." + domain) else { return false }
// A Secure cookie over plain HTTP is not sent, which is the whole point of the flag.
if c.isSecure && url.scheme != "https" { return false }
return url.path.hasPrefix(c.path) || c.path == "/"
}
}
}

79
ios/Sources/Library.swift Normal file
View File

@@ -0,0 +1,79 @@
import Foundation
import UIKit
/// The bits of ipx's API the native side writes back to, and the artwork it shows.
///
/// Small on purpose. The page does the browsing; this is only what the host has to do when the
/// page cannot -- which is any time the screen is locked, and all the time in CarPlay.
final class Library {
private let cookies: CookieBridge
private let session: URLSession
init(cookies: CookieBridge) {
self.cookies = cookies
let cfg = URLSessionConfiguration.default
cfg.httpCookieStorage = HTTPCookieStorage.shared
cfg.httpShouldSetCookies = true
cfg.waitsForConnectivity = true
self.session = URLSession(configuration: cfg)
}
struct Unauthorized: Error {}
/// Where you are in an episode. The page sends this too, but only while it is running:
/// a backgrounded web view is frozen, and this is the drive it would miss.
func savePosition(feedId: String, guid: String, secs: Int, duration: Int?) {
post("/api/entries/\(esc(feedId))/\(esc(guid))/position", position(secs, duration))
}
func markRead(feedId: String, guid: String) {
post("/api/entries/\(esc(feedId))/\(esc(guid))/flags", ["read": true])
}
/// A path the page handed over, used as-is. It already names the right item, and rebuilding it
/// here would be a second place for the escaping to be wrong.
func savePosition(path: String, secs: Int, duration: Int?) {
post(path, position(secs, duration))
}
/// `duration` is left out when there is none rather than sent as null: it is Option<i64> on
/// the server either way, and `duration as Any` on a nil Optional is an Any wrapping nil,
/// which JSONSerialization refuses -- leaving the request with no body at all.
private func position(_ secs: Int, _ duration: Int?) -> [String: Any] {
var body: [String: Any] = ["secs": secs]
if let duration { body["duration"] = duration }
return body
}
func artwork(_ path: String, done: @escaping (UIImage?) -> Void) {
guard let url = ServerSettings.url(path) else { return done(nil) }
session.dataTask(with: url) { data, _, _ in
done(data.flatMap(UIImage.init(data:)))
}.resume()
}
/// Whether the session is still good, so a failure can say "sign in again" rather than stall.
func check(done: @escaping (Bool) -> Void) {
guard let url = ServerSettings.url("/api/me") else { return done(false) }
session.dataTask(with: url) { _, resp, _ in
done((resp as? HTTPURLResponse)?.statusCode == 200)
}.resume()
}
private func post(_ path: String, _ body: [String: Any]) {
guard let url = ServerSettings.url(path) else { return }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
session.dataTask(with: req) { _, resp, err in
if let code = (resp as? HTTPURLResponse)?.statusCode, code >= 400 {
NSLog("ipx: %@ -> %d", path, code)
} else if let err { NSLog("ipx: %@ -> %@", path, err.localizedDescription) }
}.resume()
}
private func esc(_ s: String) -> String {
s.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(.init(charactersIn: "-._~"))) ?? s
}
}

265
ios/Sources/Playback.swift Normal file
View File

@@ -0,0 +1,265 @@
import AVFoundation
import Foundation
import MediaPlayer
/// The host's player: the one CarPlay and a car's own buttons can reach, and the one that keeps
/// going when the screen locks. Knows nothing about the web view -- the bridge drives it, and so
/// will a CarPlay scene.
final class Playback: NSObject {
/// What is loaded, enough to keep the car's screen right and to save a position without
/// asking anyone.
struct Item {
var url: URL
var feedId: String
var guid: String
var title: String
var feedTitle: String
var artwork: String?
var position: Int
var duration: Int?
}
/// Where playback state goes: the web page, through the bridge. A CarPlay scene would take
/// the same callbacks.
var onTime: ((Double, Double?) -> Void)?
var onMeta: ((Double) -> Void)?
var onState: ((Bool) -> Void)?
var onEnded: (() -> Void)?
var onError: ((String) -> Void)?
private let cookies: CookieBridge
private let library: Library
private var player: AVPlayer?
private var item: Item?
private var timeObserver: Any?
private var statusObservation: NSKeyValueObservation?
private var lastSaved: Double = 0
private var markedRead = false
var isPlaying: Bool { player?.timeControlStatus == .playing }
var currentTime: Double { player?.currentTime().seconds ?? 0 }
var duration: Double? {
guard let d = player?.currentItem?.duration.seconds, d.isFinite, d > 0 else { return nil }
return d
}
init(cookies: CookieBridge, library: Library) {
self.cookies = cookies
self.library = library
super.init()
configureSession()
wireRemoteCommands()
}
/// `.spokenAudio` is the podcast mode: it is what tells the system this is speech, so the
/// right thing happens when navigation talks over it.
private func configureSession() {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, policy: .longFormAudio)
} catch {
NSLog("ipx: audio session: %@", error.localizedDescription)
}
}
// MARK: - what the page asks for
func load(_ item: Item) {
stop(notify: false)
self.item = item
self.markedRead = false
self.lastSaved = 0
// The session cookie is how ipx knows who is asking, and an AVURLAsset does not read the
// shared store on its own.
let asset = AVURLAsset(url: item.url, options: [
AVURLAssetHTTPCookiesKey: cookies.cookies(for: item.url),
])
let playerItem = AVPlayerItem(asset: asset)
let player = AVPlayer(playerItem: playerItem)
// Seeking has to land where it was asked to: the default tolerance snaps to keyframes,
// which on a long episode is minutes away from where you let go of the scrubber.
player.automaticallyWaitsToMinimizeStalling = true
self.player = player
// Whose position this is belongs to the page, on loadedmetadata, so one piece of code
// decides it. Here we only report that the length is known.
statusObservation = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
guard let self else { return }
switch pi.status {
case .readyToPlay:
if let d = self.duration { self.onMeta?(d) }
case .failed:
let msg = pi.error?.localizedDescription ?? "the file could not be played"
self.library.check { good in
DispatchQueue.main.async { self.onError?(good ? msg : "sign in again") }
}
default: break
}
}
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main
) { [weak self] t in self?.tick(t.seconds) }
NotificationCenter.default.addObserver(
self, selector: #selector(didEnd),
name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
updateNowPlaying()
}
func play() {
guard let player else { return }
try? AVAudioSession.sharedInstance().setActive(true)
player.play()
onState?(true)
updateNowPlaying()
}
func pause() {
player?.pause()
onState?(false)
savePosition()
updateNowPlaying()
}
func seek(to secs: Double) {
guard let player else { return }
player.seek(to: CMTime(seconds: max(0, secs), preferredTimescale: 600),
toleranceBefore: .zero, toleranceAfter: .zero)
updateNowPlaying()
}
func setRate(_ r: Float) {
// Assigning to `rate` also starts playback, which is not what changing the speed of a
// paused episode should do.
player?.defaultRate = r
if isPlaying { player?.rate = r }
updateNowPlaying()
}
func setVolume(_ v: Float) { player?.volume = v }
func stop(notify: Bool = true) {
savePosition()
if let timeObserver { player?.removeTimeObserver(timeObserver) }
timeObserver = nil
statusObservation = nil
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
player?.pause()
player = nil
item = nil
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
if notify { onState?(false) }
}
/// The page asking us to save, because its own beacon is ours now. It sends the path rather
/// than a time: the time it has may be minutes old if it has been frozen in the background.
func savePosition(path: String? = nil) {
guard let item, let player, player.currentItem?.status == .readyToPlay else { return }
let secs = Int(player.currentTime().seconds.rounded())
guard secs > 0 else { return }
lastSaved = Double(secs)
let dur = duration.map { Int($0.rounded()) }
if let path {
library.savePosition(path: path, secs: secs, duration: dur)
} else {
library.savePosition(feedId: item.feedId, guid: item.guid, secs: secs, duration: dur)
}
}
// MARK: - as it plays
private func tick(_ secs: Double) {
guard secs.isFinite else { return }
onTime?(secs, duration)
updateElapsed(secs)
if abs(secs - lastSaved) > 10 { savePosition() }
// Listened to, not merely started -- the same 90% the page uses. Doing it on play made an
// item vanish from Unread the instant you pressed it.
if let d = duration, d > 0, secs / d >= 0.9 { markRead() }
}
@objc private func didEnd() {
savePosition()
markRead()
onState?(false)
onEnded?()
updateNowPlaying()
}
private func markRead() {
guard !markedRead, let item else { return }
markedRead = true
library.markRead(feedId: item.feedId, guid: item.guid)
}
// MARK: - the car and the lock screen
private func wireRemoteCommands() {
let c = MPRemoteCommandCenter.shared()
c.playCommand.addTarget { [weak self] _ in self?.play(); return .success }
c.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success }
c.togglePlayPauseCommand.addTarget { [weak self] _ in
guard let self else { return .commandFailed }
self.isPlaying ? self.pause() : self.play()
return .success
}
// The same 15 and 30 the page uses, so a steering wheel and the keyboard agree.
c.skipBackwardCommand.preferredIntervals = [15]
c.skipForwardCommand.preferredIntervals = [30]
c.skipBackwardCommand.addTarget { [weak self] _ in
guard let self else { return .commandFailed }
self.seek(to: self.currentTime - 15); return .success
}
c.skipForwardCommand.addTarget { [weak self] _ in
guard let self else { return .commandFailed }
self.seek(to: self.currentTime + 30); return .success
}
c.changePlaybackPositionCommand.addTarget { [weak self] ev in
guard let self, let e = ev as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
self.seek(to: e.positionTime); return .success
}
}
private func updateNowPlaying() {
guard let item else { return }
var info: [String: Any] = [
MPMediaItemPropertyTitle: item.title,
MPMediaItemPropertyArtist: item.feedTitle,
MPMediaItemPropertyAlbumTitle: item.feedTitle,
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? (player?.rate ?? 1) : 0,
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
]
if let d = duration ?? item.duration.map(Double.init) {
info[MPMediaItemPropertyPlaybackDuration] = d
}
let centre = MPNowPlayingInfoCenter.default()
centre.nowPlayingInfo = info
if let art = item.artwork {
library.artwork(art) { image in
guard let image else { return }
DispatchQueue.main.async {
// Read back rather than reusing `info`: by the time the picture arrives the
// elapsed time in it is stale, and writing it would jerk the car's progress bar.
var latest = centre.nowPlayingInfo ?? [:]
latest[MPMediaItemPropertyArtwork] =
MPMediaItemArtwork(boundsSize: image.size) { _ in image }
centre.nowPlayingInfo = latest
}
}
}
}
/// Just the clock, every tick. Rewriting the whole dictionary that often makes the car's
/// progress bar stutter.
private func updateElapsed(_ secs: Double) {
guard var info = MPNowPlayingInfoCenter.default().nowPlayingInfo else { return }
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = secs
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? (player?.rate ?? 1) : 0
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
}

View File

@@ -0,0 +1,14 @@
import UIKit
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let w = UIWindow(windowScene: windowScene)
w.rootViewController = WebViewController()
w.makeKeyAndVisible()
window = w
}
}

View File

@@ -0,0 +1,55 @@
import Foundation
/// Where ipx is. Asked for on first run and kept, because there is more than one way in: the
/// Cloudflare tunnel from anywhere, and a LAN address at home. Nothing here is a secret --
/// signing in happens in the web view, and the cookies it leaves behind authenticate the rest.
enum ServerSettings {
private static let key = "ipx.server"
/// What the setup screen starts with. A suggestion, not a default: nothing is stored until
/// it is saved, so the first run always asks.
static let suggestion = "https://ipodderx.sdf1.net"
static var base: URL? {
get { UserDefaults.standard.string(forKey: key).flatMap(parse) }
set { UserDefaults.standard.set(newValue?.absoluteString, forKey: key) }
}
static var isConfigured: Bool { base != nil }
/// What someone typed, as an address, or nil if it cannot be one.
///
/// A bare host gets https, because typing `ipodderx.sdf1.net` is the common case and
/// `URL(string:)` would otherwise hand back something with no scheme that fails much later,
/// in a network error nobody can act on.
static func parse(_ raw: String) -> URL? {
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
if !text.contains("://") { text = "https://" + text }
guard let url = URL(string: text),
let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https",
let host = url.host, !host.isEmpty else { return nil }
return url
}
/// Whether plain HTTP will actually be allowed to load. The app permits it on a local
/// network and nowhere else, so this is the difference between a clear warning at setup and
/// a blocked request much later.
static func isLocal(_ url: URL) -> Bool {
guard let host = url.host?.lowercased() else { return false }
if host == "localhost" || host.hasSuffix(".local") || host == "127.0.0.1" || host == "::1" { return true }
if host.hasPrefix("192.168.") || host.hasPrefix("10.") || host.hasPrefix("169.254.") { return true }
// 172.16.0.0/12 is the one that needs arithmetic rather than a prefix.
let parts = host.split(separator: ".")
if parts.count == 4, parts[0] == "172", let second = Int(parts[1]), (16...31).contains(second) { return true }
return false
}
/// A path the page gave us, against the server. The page speaks in absolute paths
/// (`/media/42`), which are relative to whichever host it was served from.
static func url(_ path: String) -> URL? {
if let u = URL(string: path), u.scheme != nil { return u }
guard let base else { return nil }
return URL(string: path, relativeTo: base)?.absoluteURL
}
}

View File

@@ -0,0 +1,105 @@
import UIKit
/// Asks where ipx is. Shown on first run, and again whenever the address needs changing.
final class ServerSetupViewController: UIViewController, UITextFieldDelegate {
var onSave: ((URL) -> Void)?
private let field = UITextField()
private let note = UILabel()
private let first: Bool
/// The first run has nothing to go back to, so it gets no Cancel.
init(first: Bool) {
self.first = first
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("not from a storyboard") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = first ? "Where is ipx?" : "Server"
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Save", style: .done, target: self, action: #selector(save))
if !first {
navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "Cancel", style: .plain, target: self, action: #selector(cancel))
}
let blurb = UILabel()
blurb.text = "The address of your ipx server. Sign in on the page that follows; the app plays "
+ "what you are signed in to."
blurb.numberOfLines = 0
blurb.font = .preferredFont(forTextStyle: .subheadline)
blurb.textColor = .secondaryLabel
field.text = ServerSettings.base?.absoluteString ?? ServerSettings.suggestion
field.placeholder = ServerSettings.suggestion
field.borderStyle = .roundedRect
field.keyboardType = .URL
field.textContentType = .URL
field.autocapitalizationType = .none
field.autocorrectionType = .no
field.spellCheckingType = .no
field.clearButtonMode = .whileEditing
field.returnKeyType = .done
field.delegate = self
field.addTarget(self, action: #selector(edited), for: .editingChanged)
note.numberOfLines = 0
note.font = .preferredFont(forTextStyle: .footnote)
note.textColor = .secondaryLabel
let stack = UIStackView(arrangedSubviews: [blurb, field, note])
stack.axis = .vertical
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 24),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
])
edited()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
field.becomeFirstResponder()
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool { save(); return true }
/// Says what is wrong while it is still being typed, rather than after a failed load.
@objc private func edited() {
let typed = field.text ?? ""
guard let url = ServerSettings.parse(typed) else {
note.text = typed.isEmpty ? "" : "That is not a web address."
note.textColor = .secondaryLabel
navigationItem.rightBarButtonItem?.isEnabled = false
return
}
navigationItem.rightBarButtonItem?.isEnabled = true
if url.scheme == "http" && !ServerSettings.isLocal(url) {
// Saying so here beats a blocked request later that reads like the server is down.
note.text = "Plain HTTP is only allowed on your own network. This address will not load."
note.textColor = .systemOrange
} else if url.scheme == "http" {
note.text = "On your own network, so plain HTTP is fine."
note.textColor = .secondaryLabel
} else {
note.text = "Will open \(url.absoluteString)"
note.textColor = .secondaryLabel
}
}
@objc private func save() {
guard let url = ServerSettings.parse(field.text ?? "") else { return }
ServerSettings.base = url
field.resignFirstResponder()
onSave?(url)
}
@objc private func cancel() { dismiss(animated: true) }
}

View File

@@ -0,0 +1,203 @@
import UIKit
import WebKit
/// The app, very nearly: ipx's own page, full screen.
///
/// What it adds is the bridge to the host's player, a way to say which server, and a line of
/// honesty when something is wrong.
final class WebViewController: UIViewController, WKNavigationDelegate {
private var webView: WKWebView!
private var cookies: CookieBridge!
private var library: Library!
private var playback: Playback!
private var bridge: Bridge!
private let banner = UIView()
private let bannerText = UILabel()
private var checkedBridge = false
/// The page sits below the banner when there is one, and at the top when there is not. Two
/// constraints rather than an overlay: the banner covered the page's own toolbar, and ipx
/// lays that out itself, so there is nothing to scroll out of the way.
private var pageBelowBanner: NSLayoutConstraint!
private var pageAtTop: NSLayoutConstraint!
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 so there are cookies for the player to use at all.
cfg.websiteDataStore = .default()
cfg.allowsInlineMediaPlayback = true
// Video still plays in the page, and it should not need a second tap to start.
cfg.mediaTypesRequiringUserActionForPlayback = []
webView = WKWebView(frame: .zero, configuration: cfg)
webView.navigationDelegate = self
webView.allowsBackForwardNavigationGestures = false
webView.scrollView.contentInsetAdjustmentBehavior = .never
webView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(webView)
pageAtTop = webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
NSLayoutConstraint.activate([
pageAtTop,
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
cookies = CookieBridge(store: cfg.websiteDataStore.httpCookieStore)
library = Library(cookies: cookies)
playback = Playback(cookies: cookies, library: library)
bridge = Bridge(webView: webView, playback: playback)
bridge.onReady = { [weak self] _ in
self?.checkedBridge = true
self?.setBanner(nil)
}
cfg.userContentController.add(bridge, name: Bridge.handlerName)
setUpBanner()
if ServerSettings.isConfigured { load() }
}
// MARK: - which server
/// Shake to change it. There is nowhere on screen to put a button -- the page fills it, and
/// this is a setting touched about once -- so the way back is a gesture and the banner.
override var canBecomeFirstResponder: Bool { true }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
becomeFirstResponder()
// Not from viewDidLoad: presenting before the view is in a window gets you a blank screen
// and no error, which is exactly what it did.
if !ServerSettings.isConfigured && presentedViewController == nil {
showSetup(animated: animated)
}
}
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
if motion == .motionShake && presentedViewController == nil { showSetup(animated: true) }
}
@objc private func showSetup() { showSetup(animated: true) }
private func showSetup(animated: Bool) {
let setup = ServerSetupViewController(first: !ServerSettings.isConfigured)
setup.onSave = { [weak self] _ in
self?.dismiss(animated: true)
self?.setBanner(nil)
self?.playback.stop()
self?.load()
}
let nav = UINavigationController(rootViewController: setup)
// A first run has nothing behind it, so it cannot be swiped away unanswered.
nav.isModalInPresentation = !ServerSettings.isConfigured
present(nav, animated: animated)
}
private func load() {
guard let base = ServerSettings.base else { return showSetup(animated: true) }
// Sync first: the page is fetched with the cookies too, and without them the first
// request is a redirect to a sign-in that is not actually needed.
cookies.sync { [weak self] in
DispatchQueue.main.async { self?.webView.load(URLRequest(url: base)) }
}
}
// MARK: - navigation
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// The bridge announces itself on load. A server whose page predates it never will, and the
// app would otherwise look identical while playing nothing in the background.
checkedBridge = false
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
guard let self, !self.checkedBridge else { return }
self.webView.evaluateJavaScript("!!(window.ipxNative)") { got, _ in
if (got as? Bool) != true {
self.setBanner("This server's page has no native bridge. Playback stays in the app; "
+ "background and car playback are off.")
}
}
}
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
showFailure(error)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
showFailure(error)
}
private func showFailure(_ error: Error) {
// -999 is a load cancelled because another started, which is not a failure to report.
if (error as NSError).code == NSURLErrorCancelled { return }
let host = ServerSettings.base?.host ?? "the server"
setBanner("Cannot reach \(host): \(error.localizedDescription)")
}
// MARK: - the banner
private func setUpBanner() {
banner.backgroundColor = UIColor(red: 0.55, green: 0.12, blue: 0.12, alpha: 1)
banner.isHidden = true
banner.translatesAutoresizingMaskIntoConstraints = false
bannerText.numberOfLines = 0
bannerText.font = .preferredFont(forTextStyle: .footnote)
bannerText.textColor = .white
let change = UIButton(type: .system)
change.setTitle("Server", for: .normal)
change.titleLabel?.font = .preferredFont(forTextStyle: .footnote)
change.tintColor = .white
change.setContentHuggingPriority(.required, for: .horizontal)
// Without this the label takes the whole row and the button truncates to an ellipsis,
// which is not a thing anyone will tap.
change.setContentCompressionResistancePriority(.required, for: .horizontal)
change.addTarget(self, action: #selector(showSetup as () -> Void), for: .touchUpInside)
let dismiss = UIButton(type: .system)
dismiss.setImage(UIImage(systemName: "xmark"), for: .normal)
dismiss.tintColor = .white
dismiss.accessibilityLabel = "Hide this message"
dismiss.setContentHuggingPriority(.required, for: .horizontal)
dismiss.setContentCompressionResistancePriority(.required, for: .horizontal)
dismiss.addTarget(self, action: #selector(hideBanner), for: .touchUpInside)
let row = UIStackView(arrangedSubviews: [bannerText, change, dismiss])
row.spacing = 12
row.alignment = .center
row.isLayoutMarginsRelativeArrangement = true
row.layoutMargins = .init(top: 8, left: 12, bottom: 8, right: 12)
row.translatesAutoresizingMaskIntoConstraints = false
banner.addSubview(row)
view.addSubview(banner)
pageBelowBanner = webView.topAnchor.constraint(equalTo: banner.bottomAnchor)
NSLayoutConstraint.activate([
banner.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
banner.leadingAnchor.constraint(equalTo: view.leadingAnchor),
banner.trailingAnchor.constraint(equalTo: view.trailingAnchor),
row.topAnchor.constraint(equalTo: banner.topAnchor),
row.bottomAnchor.constraint(equalTo: banner.bottomAnchor),
row.leadingAnchor.constraint(equalTo: banner.leadingAnchor),
row.trailingAnchor.constraint(equalTo: banner.trailingAnchor),
])
}
/// Dismissed by hand. It says something true, but it says it once -- a permanent stripe
/// across a page that otherwise works is worse than the thing it is warning about.
@objc private func hideBanner() { setBanner(nil) }
private func setBanner(_ text: String?) {
bannerText.text = text
banner.isHidden = text == nil
pageAtTop.isActive = text == nil
pageBelowBanner.isActive = text != nil
view.layoutIfNeeded()
}
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
}