Siri: App Intents with phrases that need no setup: Resume, Pause, Skip forward, Skip back, Set a sleep timer, How many unread. The playback ones are AudioPlaybackIntents, run in the app and allowed to start audio from the background. Playback is now one shared instance (Playback.shared) so Siri and the widget reach it with no page loaded. It remembers the last episode and its place (LastPlayed), so Resume works with the app closed; the page, opened afterwards, does not know about an episode started that way. Widget: small and medium on the home screen, circular and rectangular on the lock screen. It shows the unread count and the last episode, with a Resume or Pause button (PlayPauseIntent, shared with Siri). It draws what the app writes to the App Group group.net.sdf1.ipx. The app asks for a redraw on play, pause, a new episode or a new count, not on every position, since iOS rations redraws. Shared/ builds into both targets, with WIDGET set in the widget's, where the player does not exist. ipx's push now carries content-available, which wakes the closed app for a moment to give the widget the count the icon just got (#433). Tested: unit tests for the snapshot and the remembered place, 19 in all; in the simulator, playing an episode wrote the snapshot to the App Group. Not testable there: Siri, and the widget drawn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
8.6 KiB
Swift
184 lines
8.6 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!
|
|
private var playback: Playback { .shared }
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .black
|
|
makeWebView()
|
|
}
|
|
|
|
/// Made again when the server changes, since whether it is app-bound is fixed when a web view
|
|
/// is made.
|
|
private func makeWebView() {
|
|
if let old = webView {
|
|
old.configuration.userContentController.removeScriptMessageHandler(forName: Bridge.name)
|
|
old.removeFromSuperview()
|
|
}
|
|
let cfg = WKWebViewConfiguration()
|
|
// A server in the Info.plist's WKAppBoundDomains gets the page's service worker, so iPX
|
|
// opens with no connection (#435); WebKit runs none in an app otherwise. The price is that
|
|
// the web view goes nowhere else, which decidePolicyFor sends to Safari. For any other
|
|
// server WebKit also cuts the bridge, so the page plays its own audio there.
|
|
cfg.limitsNavigationsToAppBoundDomains = Server.isAppBound(Server.url)
|
|
// 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: "Player Token…", style: .default) { [weak self] _ in
|
|
self?.askForCredentials()
|
|
})
|
|
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.makeWebView()
|
|
self.webView.load(URLRequest(url: url))
|
|
})
|
|
present(alert, animated: true)
|
|
}
|
|
|
|
/// The token the player and CarPlay use in place of the page's cookie, and the address they
|
|
/// send it to (#419). An empty token field keeps the one there is; Clear removes it.
|
|
private func askForCredentials(_ problem: String? = nil) {
|
|
guard presentedViewController == nil else { return }
|
|
let alert = UIAlertController(
|
|
title: "Player token",
|
|
message: problem ?? "An ipx API token (Settings, API tokens) so playback and CarPlay keep "
|
|
+ "working when the page's sign-in ends. "
|
|
+ (Credentials.isSet ? "Set now; leave the token empty to keep it." : "Not set."),
|
|
preferredStyle: .alert)
|
|
alert.addTextField { f in
|
|
f.placeholder = "ipx API token"
|
|
f.isSecureTextEntry = true
|
|
f.autocapitalizationType = .none
|
|
f.autocorrectionType = .no
|
|
}
|
|
alert.addTextField { f in
|
|
f.text = Server.apiURL?.absoluteString ?? Server.suggestedAPI
|
|
f.keyboardType = .URL
|
|
f.autocapitalizationType = .none
|
|
f.autocorrectionType = .no
|
|
}
|
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
|
alert.addAction(UIAlertAction(title: "Clear", style: .destructive) { _ in
|
|
Credentials.apiToken = nil
|
|
})
|
|
alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak self, weak alert] _ in
|
|
let fields = alert?.textFields ?? []
|
|
guard fields.count == 2, let url = Server.normalise(fields[1].text ?? ""),
|
|
url.scheme == "https" else {
|
|
self?.askForCredentials("The player address has to be https, such as \(Server.suggestedAPI).")
|
|
return
|
|
}
|
|
Server.apiURL = url
|
|
if let token = fields[0].text, !token.isEmpty { Credentials.apiToken = token }
|
|
})
|
|
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
|
|
}
|
|
|
|
/// Bound to its domains, the web view cannot open a link to anywhere else, and failing to
|
|
/// would say the server cannot be reached. So such a link goes to Safari instead.
|
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
|
decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void) {
|
|
guard webView.configuration.limitsNavigationsToAppBoundDomains,
|
|
navigationAction.targetFrame?.isMainFrame != false,
|
|
let url = navigationAction.request.url, !Server.isAppBound(url),
|
|
url.scheme == "http" || url.scheme == "https" else {
|
|
decisionHandler(.allow)
|
|
return
|
|
}
|
|
UIApplication.shared.open(url)
|
|
decisionHandler(.cancel)
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|