iOS: the app's settings in Settings, not behind a shake; iPhone upright (#445)

The server address and the player token were set from alerts that
opened on shaking the phone, which nobody finds. Settings now has a This
device section in the app: the server, the player token and its
address, and a Make button that creates an API token and hands it
straight to the app's keychain. The app sends what it has at ready
(appSettings) and takes setServer, setToken and clearToken; a new
server rebuilds the web view as the alert did. The first-launch prompt
and the Cannot reach alert stay native, since there is no page then.

The iPhone is portrait only; the iPad keeps all four orientations, which
Split View and Slide Over need (#434 checked the iPad's layout).

Build 14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-13 08:39:54 -05:00
parent 69f1a38e9f
commit 171c77475a
10 changed files with 142 additions and 61 deletions

View File

@@ -62,6 +62,37 @@ final class Bridge: NSObject, WKScriptMessageHandler {
"keeping": Offline.shared.downloading.sorted()])
}
/// A new server, checked, for the web view to load in place of this page (#445).
var onServer: ((URL) -> Void)?
/// The app's own settings for the page's Settings to show (#445); `saved` says a change took.
private func sendAppSettings(saved: Bool = false) {
send(["t": "appSettings", "server": Server.url?.absoluteString ?? Server.suggested,
"api": Server.apiURL?.absoluteString ?? Server.suggestedAPI,
"tokenSet": Credentials.apiToken != nil, "saved": saved])
}
/// The page's Settings, setting what an alert on shaking the phone used to (#445).
private func setToken(_ m: [String: Any]) {
guard let url = Server.normalise(m["api"] as? String ?? ""), url.scheme == "https" else {
send(["t": "appSettingsError",
"message": "The player address has to be https, such as \(Server.suggestedAPI)."])
return
}
Server.apiURL = url
if let token = m["token"] as? String, !token.isEmpty { Credentials.apiToken = token }
sendAppSettings(saved: true)
}
private func setServer(_ text: String) {
guard let url = Server.normalise(text) else {
send(["t": "appSettingsError", "message": "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
}
onServer?(url)
}
/// 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.
@@ -91,7 +122,8 @@ final class Bridge: NSObject, WKScriptMessageHandler {
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"]])
send(["t": "host", "can": ["sleep", "route", "settings"]])
sendAppSettings()
// 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()
@@ -123,6 +155,11 @@ final class Bridge: NSObject, WKScriptMessageHandler {
case "route": showRoutes(m)
case "haptic": haptic(m["kind"] as? String)
case "unread": if let n = m["n"] as? Int { Badge.set(n) }
case "setServer": setServer(m["url"] as? String ?? "")
case "setToken": setToken(m)
case "clearToken":
Credentials.apiToken = nil
sendAppSettings(saved: true)
default: break
}
}

View File

@@ -1,6 +1,6 @@
import Foundation
/// Which ipx-web the app shows. Asked for on first launch; shaking the phone asks again.
/// Which ipx-web the app shows. Asked for on first launch; changed in the page's Settings (#445).
/// `-ipx.server <url>` on the launch arguments overrides it for that run (UserDefaults' argument
/// domain), which is how a test points the app somewhere else.
enum Server {

View File

@@ -45,7 +45,9 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
view.addSubview(webView)
cookies = CookieBridge(store: cfg.websiteDataStore.httpCookieStore)
cfg.userContentController.add(Bridge(webView: webView, playback: playback), name: Bridge.name)
let bridge = Bridge(webView: webView, playback: playback)
bridge.onServer = { [weak self] url in self?.open(url) }
cfg.userContentController.add(bridge, name: Bridge.name)
}
override func viewDidAppear(_ animated: Bool) {
@@ -57,16 +59,13 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
// 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() }
}
/// Asked on first launch, and when the server cannot be reached; otherwise changed in the
/// page's Settings (#445), which replaced shaking the phone for it.
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.",
message: problem ?? "The address you open iPX at. You can change it later in Settings.",
preferredStyle: .alert)
alert.addTextField { f in
f.text = Server.url?.absoluteString ?? Server.suggested
@@ -76,9 +75,6 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
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 ?? ""
@@ -87,51 +83,16 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
+ "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))
self.open(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)
private func open(_ url: URL) {
Server.url = url
playback.stop()
makeWebView()
webView.load(URLRequest(url: url))
}
// MARK: - the page