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

@@ -7,12 +7,14 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.
### Added ### Added
- iPX on the iPhone: the page in an app, playing through the phone's own player, so an episode plays on with the screen locked and the lock screen, Control Center and a car's buttons drive it (#418). - iPX on the iPhone: the page in an app, playing through the phone's own player, so an episode plays on with the screen locked and the lock screen, Control Center and a car's buttons drive it (#418).
- Player token (shake, then Player Token): an ipx API token, kept in the keychain, which the player, the badge and CarPlay send to `https://ipxapi.sdf1.net` instead of using the page's sign-in cookie, so playback does not stop when that cookie expires (#419). - Player token (Settings, This device): an ipx API token, kept in the keychain, which the player, the badge and CarPlay send to `https://ipxapi.sdf1.net` instead of using the page's sign-in cookie, so playback does not stop when that cookie expires (#419).
- The icon shows All Subscriptions' unread count, as the page's badge does: set when the app comes forward, after an item is marked read, and by a refresh iOS runs now and then while the app is closed, so it can be hours behind (#421). Reading in the app and new items arriving while it is open set it at once, and while an episode plays it is checked every quarter of an hour. - The icon shows All Subscriptions' unread count, as the page's badge does: set when the app comes forward, after an item is marked read, and by a refresh iOS runs now and then while the app is closed, so it can be hours behind (#421). Reading in the app and new items arriving while it is open set it at once, and while an episode plays it is checked every quarter of an hour.
- Episodes kept on the phone, with the page's Keep button, play with no connection: the app fetches the file in the background, even with the app closed, and plays it from the phone; where you got to and what you finished offline is sent once the connection is back. Signing out removes them (#425). - Episodes kept on the phone, with the page's Keep button, play with no connection: the app fetches the file in the background, even with the app closed, and plays it from the phone; where you got to and what you finished offline is sent once the connection is back. Signing out removes them (#425).
- A sleep timer in the player, kept by the phone, so it pauses on time with the screen locked, fading out over the last ten seconds; and an AirPlay button beside it, for another speaker or a TV (#429). - A sleep timer in the player, kept by the phone, so it pauses on time with the screen locked, fading out over the last ten seconds; and an AirPlay button beside it, for another speaker or a TV (#429).
- iPX opens with no connection, showing the lists it last had, as it does in a browser: the page's service worker now runs in the app. This is for `ipodderx.sdf1.net` only, and links to other sites open in Safari (#435). - iPX opens with no connection, showing the lists it last had, as it does in a browser: the page's service worker now runs in the app. This is for `ipodderx.sdf1.net` only, and links to other sites open in Safari (#435).
- Siri and Shortcuts: "Resume iPX", "Pause iPX", "Skip forward in iPX", "Skip back in iPX", "Set a sleep timer in iPX" and "How many unread in iPX", with nothing to set up, and each an action in Shortcuts. Resume works with the app closed, carrying on with the last episode from where you left off (#427). - Siri and Shortcuts: "Resume iPX", "Pause iPX", "Skip forward in iPX", "Skip back in iPX", "Set a sleep timer in iPX" and "How many unread in iPX", with nothing to set up, and each an action in Shortcuts. Resume works with the app closed, carrying on with the last episode from where you left off (#427).
- Settings in the app has a This device section: the server the app opens and the player token, with a button to make one (#445).
- On an iPhone the app stays upright; on an iPad it turns every way, and works in Split View and Slide Over (#434, #445).
- CarPlay: In Progress, Unread and Shows on the car's screen, an episode playing from where you left off and saving its place as it does on the phone, and the car's Now Playing for the rest. It lists only shows and episodes with a downloaded audio file, and the phone's player bar takes up what the car plays (#420, #442, #443). - CarPlay: In Progress, Unread and Shows on the car's screen, an episode playing from where you left off and saving its place as it does on the phone, and the car's Now Playing for the rest. It lists only shows and episodes with a downloaded audio file, and the phone's player bar takes up what the car plays (#420, #442, #443).
- A widget for the home screen and lock screen: the unread count, and the episode you were on with a button to resume or pause it (#428). - A widget for the home screen and lock screen: the unread count, and the episode you were on with a button to resume or pause it (#428).
- The icon's count stays right with the app closed: ipx sends it to the phone when it changes, once its `[push]` is set up, in place of waiting for iOS's background refresh (#433). - The icon's count stays right with the app closed: ipx sends it to the phone when it changes, once its `[push]` is set up, in place of waiting for iOS's background refresh (#433).

View File

@@ -25,7 +25,8 @@ CarPlay does not: Apple grants `com.apple.developer.carplay-audio` on request (g
## Which server ## Which server
The first launch asks, with `https://ipodderx.sdf1.net` filled in; a bare host gets `https://`. The first launch asks, with `https://ipodderx.sdf1.net` filled in; a bare host gets `https://`.
Shake the phone to change it. Plain `http://` only on your own network Change it in the page's Settings, under This device, or from the alert that comes up when the
server cannot be reached. Plain `http://` only on your own network
(`http://192.168.1.130:8099`), which is all `NSAllowsLocalNetworking` allows. Sign in on the page (`http://192.168.1.130:8099`), which is all `NSAllowsLocalNetworking` allows. Sign in on the page
that follows, through Cloudflare Access or ipx's own form; the cookies that leaves are what the that follows, through Cloudflare Access or ipx's own form; the cookies that leaves are what the
player uses (`CookieBridge`). `-ipx.server <url>` as a launch argument overrides it for a run. `-ipx.noBadgePrompt YES` skips the badge permission dialog, which covers the page in a simulator run nobody is there to tap. player uses (`CookieBridge`). `-ipx.server <url>` as a launch argument overrides it for a run. `-ipx.noBadgePrompt YES` skips the badge permission dialog, which covers the page in a simulator run nobody is there to tap.
@@ -51,8 +52,8 @@ A debug build adds `localhost`, so a simulator can test the worker against a pag
## Player token ## Player token
The player normally rides on the web view's cookie, which ends with Cloudflare Access's session. The player normally rides on the web view's cookie, which ends with Cloudflare Access's session.
To make it outlast that, shake the phone, choose Player Token and enter an ipx API token (the To make it outlast that, open Settings in the app and, under This device, press Make, or paste an
page's Settings, API tokens) and the address to send it to, `https://ipxapi.sdf1.net`. The token is ipx API token (Settings, API tokens), with the address to send it to, `https://ipxapi.sdf1.net`. The token is
kept in the keychain (`Credentials.swift`). The player's audio, positions, read flags, artwork and kept in the keychain (`Credentials.swift`). The player's audio, positions, read flags, artwork and
the unread badge go to that address with `Authorization: Bearer`; the page still signs in as it the unread badge go to that address with `Authorization: Bearer`; the page still signs in as it
did, at the server address. Without a token the player sends only the cookie, as before. did, at the server address. Without a token the player sends only the cookie, as before.
@@ -117,6 +118,8 @@ Page to app, on `webkit.messageHandlers.ipx`:
| `{t:"sleep", secs}` | pause after `secs`, fading out over the last ten; 0 turns the timer off (#429) | | `{t:"sleep", secs}` | pause after `secs`, fading out over the last ten; 0 turns the timer off (#429) |
| `{t:"unread", n}` | All Subscriptions' unread count, whenever the page's list is drawn with a new one: the icon's badge (#421) | | `{t:"unread", n}` | All Subscriptions' unread count, whenever the page's list is drawn with a new one: the icon's badge (#421) |
| `{t:"haptic", kind}` | a press felt: `tap`, `done` (marked read) or `warn` (an error) (#430) | | `{t:"haptic", kind}` | a press felt: `tap`, `done` (marked read) or `warn` (an error) (#430) |
| `{t:"setServer", url}` | Settings' This device: open another server, which replaces the page (#445) |
| `{t:"setToken", api, token?}` `{t:"clearToken"}` | Settings' This device: the player address, and a token to keep in the keychain or none (#445) |
| `{t:"route", x, y, w, h}` | open the AirPlay picker, pointing at the page's button, in the web view's points (#429) | | `{t:"route", x, y, w, h}` | open the AirPlay picker, pointing at the page's button, in the web view's points (#429) |
App to page, as `window.ipxNative.on(...)`: App to page, as `window.ipxNative.on(...)`:
@@ -130,7 +133,9 @@ App to page, as `window.ipxNative.on(...)`:
| `{t:"error", message}` | the page says so | | `{t:"error", message}` | the page says so |
| `{t:"kept", urls, keeping}` | what the phone holds and is fetching, at `ready` and after each change; sent whether or not anything plays | | `{t:"kept", urls, keeping}` | what the phone holds and is fetching, at `ready` and after each change; sent whether or not anything plays |
| `{t:"keepFailed", url, message}` | one that could not be kept, and why | | `{t:"keepFailed", url, message}` | one that could not be kept, and why |
| `{t:"host", can}` | at `ready`: what this build does itself (`sleep`, `route`), so a page newer than the app offers only those | | `{t:"host", can}` | at `ready`: what this build does itself (`sleep`, `route`, `settings`), so a page newer than the app offers only those |
| `{t:"appSettings", server, api, tokenSet, saved}` | at `ready` and after a change: the app's server, player address and whether a player token is set, for Settings' This device (#445) |
| `{t:"appSettingsError", message}` | a server or player address the app would not take |
| `{t:"push", token, sandbox}` | at `ready` and when Apple gives a new one: the phone's push token, for the page to register with ipx (`POST /api/push/devices`) (#433) | | `{t:"push", token, sandbox}` | at `ready` and when Apple gives a new one: the phone's push token, for the page to register with ipx (`POST /api/push/devices`) (#433) |
| `{t:"sleep", left}` | at `ready`, while the timer runs: seconds left, for a page that reloaded | | `{t:"sleep", left}` | at `ready`, while the timer runs: seconds left, for a page that reloaded |
| `{t:"slept"}` | the timer ran out and playback paused | | `{t:"slept"}` | the timer ran out and playback paused |

View File

@@ -62,6 +62,37 @@ final class Bridge: NSObject, WKScriptMessageHandler {
"keeping": Offline.shared.downloading.sorted()]) "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 /// 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 /// 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. /// 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 r = m["rate"] as? Double { playback.setRate(Float(r)) }
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) } 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. // 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. // 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)]) } if let at = playback.sleepAt { send(["t": "sleep", "left": max(0, at.timeIntervalSinceNow)]) }
sendKept() sendKept()
@@ -123,6 +155,11 @@ final class Bridge: NSObject, WKScriptMessageHandler {
case "route": showRoutes(m) case "route": showRoutes(m)
case "haptic": haptic(m["kind"] as? String) case "haptic": haptic(m["kind"] as? String)
case "unread": if let n = m["n"] as? Int { Badge.set(n) } 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 default: break
} }
} }

View File

@@ -1,6 +1,6 @@
import Foundation 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 /// `-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. /// domain), which is how a test points the app somewhere else.
enum Server { enum Server {

View File

@@ -45,7 +45,9 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
view.addSubview(webView) view.addSubview(webView)
cookies = CookieBridge(store: cfg.websiteDataStore.httpCookieStore) 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) { override func viewDidAppear(_ animated: Bool) {
@@ -57,16 +59,13 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
// MARK: - which server // MARK: - which server
/// Shake to change it. A setting touched about once, so it takes no room on the screen. /// Asked on first launch, and when the server cannot be reached; otherwise changed in the
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { /// page's Settings (#445), which replaced shaking the phone for it.
if motion == .motionShake { askForServer() }
}
private func askForServer(_ problem: String? = nil) { private func askForServer(_ problem: String? = nil) {
guard presentedViewController == nil else { return } guard presentedViewController == nil else { return }
let alert = UIAlertController( let alert = UIAlertController(
title: "Your iPX server", 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) preferredStyle: .alert)
alert.addTextField { f in alert.addTextField { f in
f.text = Server.url?.absoluteString ?? Server.suggested f.text = Server.url?.absoluteString ?? Server.suggested
@@ -76,9 +75,6 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
f.clearButtonMode = .whileEditing f.clearButtonMode = .whileEditing
} }
if Server.url != nil { alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) } 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 alert.addAction(UIAlertAction(title: "Open", style: .default) { [weak self, weak alert] _ in
guard let self else { return } guard let self else { return }
let text = alert?.textFields?.first?.text ?? "" 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.") + "on your own network, such as 192.168.1.130:8099.")
return return
} }
Server.url = url self.open(url)
self.playback.stop()
self.makeWebView()
self.webView.load(URLRequest(url: url))
}) })
present(alert, animated: true) present(alert, animated: true)
} }
/// The token the player and CarPlay use in place of the page's cookie, and the address they private func open(_ url: URL) {
/// send it to (#419). An empty token field keeps the one there is; Clear removes it. Server.url = url
private func askForCredentials(_ problem: String? = nil) { playback.stop()
guard presentedViewController == nil else { return } makeWebView()
let alert = UIAlertController( webView.load(URLRequest(url: url))
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 // MARK: - the page

View File

@@ -28,7 +28,7 @@ targets:
properties: properties:
CFBundleDisplayName: iPX CFBundleDisplayName: iPX
CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: "13" CFBundleVersion: "14"
# Playing on with the screen locked. A plist key, not a signed entitlement. # Playing on with the screen locked. A plist key, not a signed entitlement.
UIBackgroundModes: [audio, fetch, remote-notification] UIBackgroundModes: [audio, fetch, remote-notification]
# The unread badge's refresh while the app is closed (#421). # The unread badge's refresh while the app is closed (#421).
@@ -47,7 +47,11 @@ targets:
- UISceneConfigurationName: CarPlay - UISceneConfigurationName: CarPlay
UISceneClassName: CPTemplateApplicationScene UISceneClassName: CPTemplateApplicationScene
UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate
UISupportedInterfaceOrientations: # Upright only on a phone, where the page's layout is made for portrait; every way on an
# iPad, which also needs all four for Split View and Slide Over.
UISupportedInterfaceOrientations~iphone:
- UIInterfaceOrientationPortrait
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeLeft
@@ -103,7 +107,7 @@ targets:
properties: properties:
CFBundleDisplayName: iPX CFBundleDisplayName: iPX
CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: "13" CFBundleVersion: "14"
NSExtension: NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension NSExtensionPointIdentifier: com.apple.widgetkit-extension
entitlements: entitlements:

View File

@@ -192,6 +192,15 @@ ok(fetched.some(f => f.startsWith('POST /api/push/devices') && f.includes('"sand
await vm.runInContext('forgetPush()', ctx); await vm.runInContext('forgetPush()', ctx);
ok(fetched.some(f => f.startsWith(`DELETE /api/push/devices/${'ab'.repeat(32)}`)), 'signing out left ipx counting for this phone'); ok(fetched.some(f => f.startsWith(`DELETE /api/push/devices/${'ab'.repeat(32)}`)), 'signing out left ipx counting for this phone');
// The app's own settings (#445), in Settings: only once the app has sent them.
ok(vm.runInContext('deviceSettingsHTML()', ctx) === '', 'Settings offered app settings the app never sent');
ctx.window.ipxNative.on({ t: 'appSettings', server: 'https://ipx.example"><b>', api: 'https://api.example', tokenSet: false });
const device = String(vm.runInContext('deviceSettingsHTML()', ctx));
ok(device.includes('https://api.example') && device.includes('Not set'), "Settings does not show the app's server and token");
ok(!device.includes('"><b>'), 'the server address went into Settings unescaped');
ctx.window.ipxNative.on({ t: 'appSettings', server: 'https://ipx.example', api: 'https://api.example', tokenSet: true });
ok(String(vm.runInContext('deviceSettingsHTML()', ctx)).includes('placeholder="Set"'), 'a saved token still shows as not set');
// CarPlay (#420): the car picks another episode, and the page's bar lets go of the old one. // CarPlay (#420): the car picks another episode, and the page's bar lets go of the old one.
vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry })); vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
body.classList.toggle('has-video', false); body.classList.toggle('has-video', false);

View File

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- In the iOS app, Settings has a This device section for the app's server and player token, with a button that makes a token and hands it to the app (#445).
- In the iOS app, the page registers the phone with ipx for its icon's count, and signing out removes it (#433). - In the iOS app, the page registers the phone with ipx for its icon's count, and signing out removes it (#433).
- In the iOS app, the player bar shows what CarPlay plays, so it can be paused on the phone after leaving the car, including when the app was opened by the car (#420, #442). - In the iOS app, the player bar shows what CarPlay plays, so it can be paused on the phone after leaving the car, including when the app was opened by the car (#420, #442).
- In the iOS app, the icon's unread count follows the page's: reading and new items change it at once, not the next time the app is opened (#421). - In the iOS app, the icon's unread count follows the page's: reading and new items change it at once, not the next time the app is opened (#421).

View File

@@ -482,6 +482,7 @@ async function prefsModal(){
placeholder="${S.me?.typesafe_key_set?'Saved. Paste a new one to replace it':'Paste your key'}"> placeholder="${S.me?.typesafe_key_set?'Saved. Paste a new one to replace it':'Paste your key'}">
<button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div> <button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div>
<span class="hint">Checked with TypeSafe before it is saved, kept on this server and never shown again.</span></div></div>`} <span class="hint">Checked with TypeSafe before it is saved, kept on this server and never shown again.</span></div></div>`}
${deviceSettingsHTML()}
<div class="field boxed"><label for="tokname">API tokens</label> <div class="field boxed"><label for="tokname">API tokens</label>
<div id="tokens"></div> <div id="tokens"></div>
<div class="inline"><input type="text" id="tokname" placeholder="What it is for"> <div class="inline"><input type="text" id="tokname" placeholder="What it is for">
@@ -498,6 +499,7 @@ async function prefsModal(){
$<HTMLSelectElement>('#sfont').onchange=(e: Event)=>setFont((e.target as HTMLSelectElement).value,true); $<HTMLSelectElement>('#sfont').onchange=(e: Event)=>setFont((e.target as HTMLSelectElement).value,true);
$<HTMLSelectElement>('#srfont').onchange=(e: Event)=>setReadingFont((e.target as HTMLSelectElement).value,true); $<HTMLSelectElement>('#srfont').onchange=(e: Event)=>setReadingFont((e.target as HTMLSelectElement).value,true);
$<HTMLButtonElement>('#gopml').onclick=opmlModal; $<HTMLButtonElement>('#gopml').onclick=opmlModal;
wireDeviceSettings();
drawTokens(); drawTokens();
$<HTMLButtonElement>('#tokadd').onclick=async()=>{ $<HTMLButtonElement>('#tokadd').onclick=async()=>{
const name=$<HTMLInputElement>('#tokname').value.trim(); if(!name){ $<HTMLInputElement>('#tokname').focus(); return; } const name=$<HTMLInputElement>('#tokname').value.trim(); if(!name){ $<HTMLInputElement>('#tokname').focus(); return; }

View File

@@ -57,6 +57,64 @@ function haptic(kind: 'tap'|'done'|'warn'){
// Here, after ipxHost exists: in the app the button waits for the host to say it has a picker. // Here, after ipxHost exists: in the app the button waits for the host to say it has a picker.
paintRoute(); paintRoute();
/// The app's own settings (#445): which server it opens, and the player token it sends instead of
/// the page's cookie (#419). They were in an alert that opened on shaking the phone, which nobody
/// found. The app sends them at ready and after each change; null in a browser, or in an app that
/// keeps them elsewhere, and then Settings shows nothing for them.
type AppSettings = {server: string, api: string, tokenSet: boolean, saved?: boolean};
let appSettings: AppSettings|null=null;
function hostAppSettings(m: AppSettings){
appSettings=m;
if(m.saved) toast('Saved on this device.');
// Settings open: drawn again, so "Not set" turns to "Set" under the person's eyes.
const box=$('#sdevice');
if(box){ box.outerHTML=deviceSettingsHTML(); wireDeviceSettings(); }
}
function deviceSettingsHTML(){
const a=appSettings;
if(!a) return '';
return `<div class="field boxed" id="sdevice"><label>This device</label>
<span class="hint">The iPX app's own settings, kept on this phone.</span>
<label for="sserver">Server</label>
<div class="inline"><input type="text" inputmode="url" id="sserver" value="${esc(a.server)}" autocapitalize="off" autocomplete="off" spellcheck="false">
<button class="btn" id="sserverset">Open</button></div>
<span class="hint">The address the app opens. Plain http only works on your own network, such as 192.168.1.130:8099.</span>
<label for="sptoken">Player token</label>
<div class="inline"><input type="password" id="sptoken" autocomplete="off" spellcheck="false"
placeholder="${a.tokenSet?'Set':'Not set'}">
<button class="btn" id="sptokenmake" title="Make a token for this device and save it" aria-label="Make a player token">${ICON.plus} Make</button>
<button class="btn ico" id="sptokenclear" title="Clear the player token" aria-label="Clear the player token"${a.tokenSet?'':' hidden'}>${ICON.trash}</button></div>
<label for="sapi">Player address</label>
<div class="inline"><input type="text" inputmode="url" id="sapi" value="${esc(a.api)}" autocapitalize="off" autocomplete="off" spellcheck="false">
<button class="btn" id="sptokensave">Save</button></div>
<span class="hint">With a token, playback and CarPlay keep working when this page's sign-in
ends. Make one here, or paste one from API tokens below. It is kept in the phone's keychain.</span></div>`;
}
function wireDeviceSettings(){
if(!appSettings||!ipxHost) return;
const host=ipxHost;
$<HTMLButtonElement>('#sserverset').onclick=()=>{
const url=$<HTMLInputElement>('#sserver').value.trim();
// The app checks it, and on a new server loads it in place of this page.
if(url&&url!==appSettings?.server) host({t:'setServer', url});
};
const api_=()=>$<HTMLInputElement>('#sapi').value.trim();
$<HTMLButtonElement>('#sptokensave').onclick=()=>{
const token=$<HTMLInputElement>('#sptoken').value.trim();
host({t:'setToken', api:api_(), ...(token?{token}:{})});
};
$<HTMLButtonElement>('#sptokenmake').onclick=async()=>{
try{
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name:'iPX app on a phone'})});
host({t:'setToken', api:api_(), token:t.token});
drawTokens();
}catch(err){ toast(why(err),true); }
};
$<HTMLButtonElement>('#sptokenclear').onclick=()=>host({t:'clearToken'});
}
function installNativePlayback(){ function installNativePlayback(){
const post = ipxHost!; const post = ipxHost!;
const M = HTMLMediaElement.prototype; const M = HTMLMediaElement.prototype;
@@ -207,6 +265,8 @@ function installNativePlayback(){
if(m.t==='host'){ hostCan.clear(); for(const c of m.can||[]) hostCan.add(c); paintRoute(); return; } if(m.t==='host'){ hostCan.clear(); for(const c of m.can||[]) hostCan.add(c); paintRoute(); return; }
if(m.t==='sleep'||m.t==='slept'){ hostSleep(m); return; } if(m.t==='sleep'||m.t==='slept'){ hostSleep(m); return; }
if(m.t==='push'){ hostPush(m); return; } if(m.t==='push'){ hostPush(m); return; }
if(m.t==='appSettings'){ hostAppSettings(m); return; }
if(m.t==='appSettingsError'){ toast(m.message||'The app did not take that.', true); return; }
if(m.t==='nowPlaying'){ adopt(m); return; } if(m.t==='nowPlaying'){ adopt(m); return; }
if(!N.on) return; if(!N.on) return;
switch(m.t){ switch(m.t){