The first native surface, and the one that settles how the rest will look. Glass.swift is not a port of the stylesheet. ipx's Glass is already an approximation of what UIKit gives away -- panels are a 70% colour under blur(24px) saturate(180%) with an inset top highlight, which is a material, and the palette names Apple's own colours in its own comments. So where the CSS had to write #409cff in dark and #0055aa in light, because no single blue clears AA over the wash, this asks for the system blue and gets both. Only the wash keeps its hex values: there the colour is the design and there is no system equivalent to ask for. PlayerBarView draws what Playback knows, and Playback is the same object the lock screen and the remote commands drive, so all of them agree without anything being kept in step by hand. Back 15 and forward 30 match the page and the lock screen, so every way of skipping moves by the same amount. Seeking waits for the finger to lift rather than sending a stream of seeks at a player still answering the last one. The page's own bar is hidden rather than left to sit under ours. Its play buttons still work -- they post to the host either way -- but two bars for one player would be two sets of buttons disagreeing about what is playing, and only one of them is what CarPlay will be driving. One test had to be rewritten rather than the code: it asserted that one of the fixture's two episodes had a file, which stops being true the moment the event test asks the daemon to scan. The daemon is shared and keeps what earlier tests did to it, so the assertion now asks what the downloaded filter means instead of how many things match it today. Thirteen pass, twice over the same accumulated state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.1 KiB
ipodderx-app
The 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
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.
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/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.