admin.html's viewport meta lacked viewport-fit=cover, which index.html has. Without it env(safe-area-inset-top) is 0, so the top bar's padding did not clear the clock in the app's full-screen web view, and the back arrow sat under it and could not be tapped. Seen in the simulator, the built page served locally: the arrow and the title level with the clock before, below the status bar after. Also a launch argument, -ipx.noBadgePrompt, so a simulator run can get past the badge permission dialog. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
94 lines
4.7 KiB
Markdown
94 lines
4.7 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 |
|
|
|
|
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 |
|
|
|
|
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 device (#412) are the page's service worker's, which a `WKWebView` does
|
|
not run without App-Bound Domains; the app plays them from the server.
|
|
* 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`.
|