The reading pane: the item, its files with play, download and delete, read and pin in the toolbar, a link to the original, and the notes. The notes are a web view, deliberately. They are feed-supplied HTML that ipx has already run through ammonia, and there is no good native renderer for that -- NSAttributedString(html:) is slow, single-threaded and ugly, and writing a real one is a project. So this carries only the notes, with just enough stylesheet to belong to the app: the system font at body size, the label colours, links in the accent. Nothing else is imposed, because the markup is the publisher's. A link opens in Safari rather than inside the view, where it would replace the notes with somebody's website and leave no way back. Deleting asks first, and says when the file is shared: one file serves everyone reading the feed, so removing it is not a private act. Two goes at the navigation. A row with a selection binding on the list highlighted and went nowhere -- the binding takes the tap before the link sees it. Without the binding, NavigationLink(value:) with a matching navigationDestination still pushed nothing and gave no reason, so the link carries its own destination now, which has nothing to get wrong. Fourteen tests pass. The new one opens an item and looks for its notes, its delete button and its read control, so a pane that renders empty fails rather than passing quietly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
9.3 KiB
Markdown
173 lines
9.3 KiB
Markdown
# ipodderx-app
|
|
|
|
The [ipodderx-rs](https://git.sdf1.net/rays/ipodderx-rs) web UI in an app, with the audio played by
|
|
the phone rather than the page, so it keeps going when the screen locks and a car can control it.
|
|
|
|
## Why it is not just a web view
|
|
|
|
CarPlay and Android Auto cannot render a web view. Both are template surfaces — `CPListTemplate`
|
|
and `CPNowPlayingTemplate`, or Android's media browse tree — and the only audio they will control
|
|
is the host's own `AVPlayer` or `ExoPlayer`. So an app that is "the web UI plus CarPlay" is really
|
|
"the web UI whose audio engine is native", and that is what this is.
|
|
|
|
The page keeps its face. Everything in ipx's `web/src/player.ts` speaks to its media element
|
|
through a small surface, so `web/src/native.ts` replaces that surface on the element with one that
|
|
posts to this app. The player bar, the row buttons, the EQ bars and the keyboard shortcuts all work
|
|
as they do in a browser, with nothing in `player.ts` changed. Video still plays in the page:
|
|
CarPlay is audio-only, and a native video layer under a web view buys nothing.
|
|
|
|
## What works, and what needs an Apple account
|
|
|
|
| | |
|
|
|---|---|
|
|
| Plays with the screen locked | Yes, on a free personal team. `UIBackgroundModes: [audio]` is a plist key, not a signed entitlement |
|
|
| Lock screen and Control Center | Yes — `MPNowPlayingInfoCenter`, `MPRemoteCommandCenter` |
|
|
| A car over Bluetooth or USB audio | Yes — the head unit shows the episode and its buttons drive the app |
|
|
| CarPlay's own app on the dashboard | **No.** `com.apple.developer.carplay-audio` is granted by Apple on request and does not exist on a free team |
|
|
|
|
A free personal team also expires a build after seven days, so it is reinstalled from Xcode
|
|
weekly, and allows three apps at a time. $99/yr makes that a year and adds TestFlight; CarPlay
|
|
additionally needs a granted entitlement request at <https://developer.apple.com/carplay/>.
|
|
|
|
`Playback` and `Library` deliberately know nothing about the web view. A CarPlay scene is a
|
|
`CPTemplateApplicationSceneDelegate` over the same two objects, plus a browse tree built from
|
|
`GET /api/feeds` and `GET /api/entries` — no change to this app's shape or to the server.
|
|
|
|
## Build it
|
|
|
|
```sh
|
|
brew install xcodegen
|
|
cd ios && xcodegen generate
|
|
open iPodderX.xcodeproj
|
|
```
|
|
|
|
Pick a destination and run. **My Mac (Mac Catalyst)** is one of them: the same UIKit app in a
|
|
window, built from the same target. Nothing in the Swift is conditional -- `AVAudioSession`,
|
|
`MPNowPlayingInfoCenter` and the remote commands all exist under Catalyst -- so the Mac gets
|
|
media keys and Now Playing in Control Center for free. It has no CarPlay and no lock screen,
|
|
which is most of the point on a phone, so the Mac build is a convenience rather than the reason
|
|
any of this exists.
|
|
|
|
`SUPPORTS_MACCATALYST` is set as a build setting rather than through xcodegen's
|
|
`supportsMacCatalyst:`, which this version accepts and then writes nothing for -- the generated
|
|
project had no such setting and the Mac destination simply did not exist.
|
|
|
|
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, not the project.
|
|
|
|
## What is native and what is the page
|
|
|
|
The lists, the player and the car are native. Settings, the admin page, Directory, Popular and
|
|
OPML are ipx's own page, opened from the toolbar — they are form-heavy, rarely touched, and they
|
|
already work. An item's show notes will stay HTML too, since they are feed-supplied and sanitized
|
|
server-side and there is no good native renderer for them.
|
|
|
|
That split is the point rather than a stage: native where native is better, the page everywhere
|
|
else. The page is also still where signing in happens, and its cookies are what authenticate the
|
|
API client and the player.
|
|
|
|
## Which server
|
|
|
|
The first launch asks, with `https://ipodderx.sdf1.net` filled in. A bare host gets `https://`, so
|
|
typing `ipodderx.sdf1.net` is enough. Then sign in on the page that follows — through Cloudflare
|
|
Access, or ipx's own form — and the cookies that leaves are what the player uses.
|
|
|
|
**Shake the phone to change it**, or tap **Server** in the red banner when something is wrong.
|
|
There is no button in the chrome because there is no chrome: the page fills the screen, and this
|
|
is a setting touched about once.
|
|
|
|
Plain `http://` is allowed only on your own network — `localhost`, `*.local`, and the private
|
|
ranges — which is what `NSAllowsLocalNetworking` covers. The setup screen says so as you type
|
|
rather than letting the load fail later looking like the server is down.
|
|
|
|
For automation, `-ipx.server <url>` as a launch argument overrides the stored value for that run.
|
|
|
|
## How it hangs together
|
|
|
|
| | |
|
|
|---|---|
|
|
| `ios/Sources/App.swift` | the app delegate, and which scene is which |
|
|
| `ios/Sources/SceneDelegate.swift` | the window. iOS 27 will not run an app without a scene, and CarPlay is a second one |
|
|
| `ios/Sources/ServerSetupViewController.swift` | the first-run question, and the way back to it |
|
|
| `ios/Sources/WebViewController.swift` | the `WKWebView`, and the banner when something is wrong |
|
|
| `ios/Sources/Bridge.swift` | the messages, both directions |
|
|
| `ios/Sources/Playback.swift` | `AVPlayer`, the audio session, now-playing, the remote commands |
|
|
| `ios/Sources/API.swift` | ipx's HTTP API, typed |
|
|
| `ios/Sources/APIModels.swift` | what ipx sends: feeds, entries, enclosures |
|
|
| `ios/Sources/Events.swift` | the `/api/events` stream, and reconnecting to it |
|
|
| `ios/Sources/Glass.swift` | the Glass look: materials, palette, the wash |
|
|
| `ios/Sources/PlayerBarView.swift` | the bar along the bottom |
|
|
| `ios/Sources/LibraryStore.swift` | what the lists show, and the optimistic writes |
|
|
| `ios/Sources/FeedListView.swift` | the sidebar: places, feeds, OPML folders |
|
|
| `ios/Sources/ItemListView.swift` | the item table: filter, sort, search, paging |
|
|
| `ios/Sources/ItemDetailView.swift` | one item: its files, its controls, its notes |
|
|
| `ios/Sources/ShowNotesView.swift` | the notes, which stay HTML on purpose |
|
|
| `ios/Sources/LibraryView.swift` | the two beside each other, bar underneath |
|
|
| `ios/Sources/RootViewController.swift` | the native interface, with the page a button away |
|
|
| `ios/Sources/CookieBridge.swift` | `WKHTTPCookieStore` into `HTTPCookieStorage.shared` |
|
|
| `ios/Sources/ServerSettings.swift` | which server |
|
|
|
|
### Authentication
|
|
|
|
ipx authenticates by cookie: `ipx_session` from its own sign-in, and `CF_Authorization` from
|
|
Cloudflare Access in front of the tunnel. Its auth layer was written that way so a plain
|
|
`<audio src>` would work, which is why the player needs no API of its own — but `AVPlayer` and
|
|
`URLSession` read `HTTPCookieStorage.shared` while `WKWebView` keeps its own store, so
|
|
`CookieBridge` keeps the two in step and passes them to each asset as `AVURLAssetHTTPCookiesKey`.
|
|
|
|
*ponytail: when the Access cookie expires mid-drive, playback 401s and the fix is to open the app
|
|
and sign in again. The upgrade is per-user device tokens in ipx's auth layer, at which point this
|
|
app holds a token in the keychain and stops depending on the web view's session.*
|
|
|
|
### The messages
|
|
|
|
Page to host, on `webkit.messageHandlers.ipx`:
|
|
|
|
| | |
|
|
|---|---|
|
|
| `{t:"ready", version, rate, volume}` | the bridge installed; the app waits for this before trusting the page |
|
|
| `{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 — the page sends the path, not a time, because a backgrounded web view's time may be minutes old |
|
|
|
|
Host to page, as `window.ipxNative.on(…)`:
|
|
|
|
| | |
|
|
|---|---|
|
|
| `{t:"time", cur, dur}` | drives `timeupdate` |
|
|
| `{t:"meta", dur}` | the length is known; the page seeks to where you left off |
|
|
| `{t:"state", playing}` | drives `play` / `pause` |
|
|
| `{t:"ended"}` | |
|
|
| `{t:"error", message}` | the page toasts it |
|
|
|
|
`position` and `duration` in `load` are for the car's display. Where playback *starts* is the
|
|
page's decision, on `loadedmetadata`, so one piece of code owns it.
|
|
|
|
Position and read are written by the host, not the page: ipx's `player.ts` has been bitten before
|
|
by a stale write — "one left paused in another tab saved its older place as that tab reloaded, over
|
|
where you had got to since" — and a backgrounded web view is exactly that tab.
|
|
|
|
### Changing the bridge
|
|
|
|
The page's half lives in ipodderx-rs at `web/src/native.ts`, and `tests/native-bridge.js` there is
|
|
what holds the two ends together. A change to the protocol is a change in both repos and a deploy
|
|
of the server before the app will work.
|
|
|
|
## Layout
|
|
|
|
```
|
|
ios/ the Xcode project: iPhone, iPad and Mac Catalyst from one target
|
|
android/ not started
|
|
```
|
|
|
|
Named for what it is rather than one platform, because the half of the bridge that lives in the
|
|
page already has a branch for Android (`window.ipxAndroid`) and there is no sense renaming later.
|
|
|
|
## Android
|
|
|
|
Not started. The shape is the same — `WebView` for the page, Media3 `MediaLibraryService` and
|
|
`ExoPlayer` for the audio, `automotive_app_desc.xml` for Android Auto — and `native.ts` already has
|
|
the detection branch for it (`window.ipxAndroid.postMessage`). Android Auto needs no approval:
|
|
sideload, and turn on unknown sources in Android Auto's developer settings.
|