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>
146 lines
8.5 KiB
Markdown
146 lines
8.5 KiB
Markdown
# iPX for iOS
|
|
|
|
ipx's page in an app, with the audio played by the phone rather than the page: it plays on with
|
|
the screen locked, and the lock screen, Control Center, headphones and a car over Bluetooth or USB
|
|
drive it. Everything you see is the page, as on the home screen; the app adds the player behind it.
|
|
|
|
## Build it
|
|
|
|
On a Mac with Xcode:
|
|
|
|
```sh
|
|
brew install xcodegen
|
|
cd ios && xcodegen generate && open iPX.xcodeproj
|
|
```
|
|
|
|
Set your team under Signing & Capabilities, pick your phone, and run. The project is generated
|
|
from `project.yml`, so `.xcodeproj` is not in git: edit the yml. Nothing on Tower can build it.
|
|
|
|
A free personal team works, but a build on it expires after seven days and is reinstalled from
|
|
Xcode. A paid account ($99 a year) makes that a year and adds TestFlight. Playing with the screen
|
|
locked is a plist key (`UIBackgroundModes: audio`), not an entitlement, so it works on either.
|
|
CarPlay's own dashboard app does not: Apple grants `com.apple.developer.carplay-audio` on request.
|
|
|
|
## Which server
|
|
|
|
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
|
|
(`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
|
|
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.
|
|
|
|
### Opening with no connection
|
|
|
|
The page's service worker, which keeps iPX readable offline in a browser (#389), runs in the app
|
|
only for a server in `WKAppBoundDomains` in `project.yml` (#435): `ipodderx.sdf1.net`, with
|
|
Cloudflare Access's and Authentik's hosts for signing in. For those the web view is app-bound, and
|
|
iPX opens without a connection from the second launch on (the first installs the worker). What
|
|
that costs:
|
|
|
|
* The web view goes nowhere outside the list. A link to anywhere else opens in Safari; an
|
|
embedded player from another site in an item's text may not load.
|
|
* A server not in the list still opens, but WebKit then cuts the bridge, so the page plays its own
|
|
audio: no lock screen, no CarPlay, nothing kept on the phone. That includes the LAN address.
|
|
Another server means adding it to the list (ten at most) and building again.
|
|
* Signing in that leaves those hosts, through another identity provider, opens in Safari and does
|
|
not come back. Add its host too.
|
|
|
|
A debug build adds `localhost`, so a simulator can test the worker against a page served on the Mac.
|
|
|
|
## Player token
|
|
|
|
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
|
|
page's Settings, API tokens) and 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
|
|
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.
|
|
|
|
`ipxapi.sdf1.net` is the tunnel's route to the same ipx-web as `ipodderx.sdf1.net`, with no Access
|
|
application in front, which would turn a bearer header away. Cloudflare's firewall (a custom rule
|
|
on the `sdf1.net` zone) blocks every request to it except a bearer-token one for `/api/feeds`,
|
|
`/api/me`, `/api/art`, `/api/entries/*` and `/media/*`; ipx still checks the token.
|
|
|
|
## How it hangs together
|
|
|
|
| | |
|
|
|---|---|
|
|
| `Sources/App.swift` | the app and its one scene |
|
|
| `Sources/WebViewController.swift` | the `WKWebView`, the server question, a page that will not load |
|
|
| `Sources/Bridge.swift` | the messages to and from `web/src/native.ts` |
|
|
| `Sources/Playback.swift` | `AVPlayer`, the audio session, now playing, the remote commands, saving positions |
|
|
| `Sources/CookieBridge.swift` | the web view's cookies into `HTTPCookieStorage.shared`, for the player |
|
|
| `Sources/Server.swift` | which server |
|
|
| `Sources/Intents.swift`, `Shared/PlayPauseIntent.swift` | Siri and Shortcuts (#427) |
|
|
| `Sources/LastPlayed.swift` | the last episode and its place, so Resume works with the app closed |
|
|
| `Widgets/`, `Shared/Snapshot.swift`, `Sources/WidgetFeed.swift` | the widget (#428), and what the app writes for it |
|
|
|
|
### Siri and Shortcuts
|
|
|
|
With nothing to set up, Siri answers "Resume iPX" (or "Play iPX"), "Pause iPX", "Skip forward
|
|
in iPX", "Skip back in iPX", "Set a sleep timer in iPX" and "How many unread in iPX"; each is also
|
|
an action in the Shortcuts app. The playback ones are `AudioPlaybackIntent`s, which iOS runs in the
|
|
app and lets start audio with the phone locked. Resume carries on with what is loaded, or else the
|
|
last episode from where it got to (`LastPlayed`), which plays without the page: opening the app
|
|
afterwards shows the page with nothing in its player, while the lock screen has it.
|
|
|
|
### The widget
|
|
|
|
Small and medium on the home screen, circular and rectangular on the lock screen: All
|
|
Subscriptions' unread count and the last episode, with a Resume or Pause button (the same
|
|
`PlayPauseIntent`). The widget has no sign-in and no player, so it draws what the app last wrote
|
|
to the App Group `group.net.sdf1.ipx` (`Snapshot`), and the app asks WidgetKit to redraw on a play,
|
|
a pause, a new episode or a new count, not on every position saved, since iOS rations redraws.
|
|
With the app closed, ipx's push (#433) carries `content-available`, which wakes the app for a
|
|
moment to give the widget the new count; iOS rations those wakes too, so the widget can trail the
|
|
icon. `Shared/` is built into both targets, with `WIDGET` set in the widget's, where the player
|
|
does not exist.
|
|
|
|
## The messages
|
|
|
|
The page's half is `web/src/native.ts`, and `node tests/native-bridge.ts` holds it to these. A
|
|
change to them is a change to both halves in one commit, and the page has to be deployed before
|
|
an app that needs it.
|
|
|
|
Page to app, on `webkit.messageHandlers.ipx`:
|
|
|
|
| | |
|
|
|---|---|
|
|
| `{t:"ready", version, rate, volume}` | the bridge is in |
|
|
| `{t:"load", url, enc, feedId, guid, title, feedTitle, artwork, position, duration, rate, volume}` | the element's `src` was set to an audio file |
|
|
| `{t:"play"}` `{t:"pause"}` `{t:"stop"}` | |
|
|
| `{t:"seek", to}` `{t:"rate", v}` `{t:"volume", v}` | |
|
|
| `{t:"position", url}` | save where we are; a path, not a time, since a page frozen in the background holds an old one |
|
|
| `{t:"keep", url}` `{t:"unkeep", url}` | keep a file on the phone, or let it go: the page's path for it, `/media/42` or `/media/42/deadair` (#425) |
|
|
| `{t:"clearKept"}` | signing out: everything kept goes |
|
|
| `{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:"haptic", kind}` | a press felt: `tap`, `done` (marked read) or `warn` (an error) (#430) |
|
|
| `{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(...)`:
|
|
|
|
| | |
|
|
|---|---|
|
|
| `{t:"time", cur, dur}` | `timeupdate` |
|
|
| `{t:"meta", dur}` | `loadedmetadata`: the page seeks to where you left off |
|
|
| `{t:"state", playing}` | `play` or `pause` |
|
|
| `{t:"ended"}` | |
|
|
| `{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:"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:"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:"slept"}` | the timer ran out and playback paused |
|
|
|
|
The app writes the position (every ten seconds and on pause) and marks an item read at 90%, from
|
|
its own clock, so both happen while the page is frozen. Video stays in the page.
|
|
|
|
## Not yet
|
|
|
|
* Without the player token the cookie is the only credential: when Cloudflare Access's expires
|
|
mid-drive, playback stops with "Sign in again", and opening the app signs in.
|
|
* CarPlay's dashboard app needs Apple's entitlement first; then it is a second scene over the same
|
|
`Playback`.
|