Ray Slakinski d68f1d0d1d Opening an item reads it, and the page's keys (#11)
selectEntry in the page calls markRead, so picking a row reads it. Natively
an item could be opened, read and left, and it stayed bold. With it comes
the detail that would have been missed by guessing: on the Unread tab the
item you were reading is dropped when you move on to the next one, not
whenever a refresh next comes along, so nothing vanishes from under the
pointer mid-click. A flag that does not save rolls back and says so, where
the page toasts.

The keys are Feedly's set, from player.ts: j n and k p, shift J and K, o m
s v, shift A, r, [, space, the arrows, g then a d p l or s, and ? for the
list of them. The pair window is the page's second and a half, and p and s
mean different things with and without a g in front, which they do there
too.

They are UIKeyCommand rather than SwiftUI's onKeyPress, which is iOS 17,
and they hang off the hosting controller rather than the controller that
owns it: SwiftUI's views hold first responder, so the chain starts inside
that hierarchy and an override further up is never consulted.

What is tested is the map, in KeysTests -- that p is Previous alone and
Popular after g, that every mapped key is registered, that the window is
1.5s -- because a wrong letter there loses a shortcut silently. What is not
tested is whether a press arrives at all: the simulator drops key events
unless something is focused, and running the same tests against Catalyst,
where the keyboard is the point, needs the runner to have accessibility
permission it does not have here. That test is skipped with the reason
written in it rather than deleted or left red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 11:26:05 -04:00

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.

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, each named in the toolbar's menu rather than hidden behind one button — 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.

Description
iPodderX for iPhone, iPad and Mac: the ipodderx-rs web UI in a shell that plays the audio natively, so it keeps going with the screen locked and a car can control it.
Readme 668 KiB
Languages
Swift 99.1%
Shell 0.9%