The page asks the app for a haptic ({t:"haptic", kind}) and the app
plays the phone's own. A light tap for play, pause, flag and pull to
refresh; the system's success for marking read, one item or all; its
warning with an error toast. A browser has no host to ask, and an
older app ignores the message, so there is nothing to gate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
6.0 KiB
Markdown
107 lines
6.0 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.
|
|
|
|
## 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 |
|
|
|
|
## 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:"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:"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
|
|
|
|
* Episodes kept on the phone (#425) play with no connection, from files the app fetches with a
|
|
background `URLSession` (`Offline.swift`, `EpisodeStore.swift`); a place or a finish reached
|
|
offline waits in `Outbox.swift` until the connection is back. But the page itself does not open
|
|
without a connection: a `WKWebView` runs no service worker without App-Bound Domains (#435). So
|
|
keep what you want, open the app while online, and it plays on with the connection gone.
|
|
* 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`.
|