A WKWebView runs no service worker, so the page's Keep button (#412) was not shown in the app and everything played from the server. Now the page asks the app to keep the file (keep, unkeep, clearKept), and the app answers with what it holds (kept, keepFailed) whether or not anything plays. The app fetches with a background URLSession, so a download goes on with the app closed, into Application Support (not backed up; the files can be fetched again), and Playback plays from the file when there is one. The DeadAir copy is kept under its own path, as the browser keeps it. Places and finishes reached offline wait in an outbox, newest per address, and go when the network is back. Checked in the simulator against a local server: kept /media/42 and /media/42/deadair, then with the server refusing /media, a fresh launch played each from the phone and asked the server for nothing. The page itself still needs a connection to open (#435). Deploy the app (build 6) before the page: a new page in an old app shows a Keep button nothing answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
101 lines
5.4 KiB
Markdown
101 lines
5.4 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 |
|
|
|
|
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 |
|
|
|
|
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`.
|