Commit Graph

15 Commits

Author SHA1 Message Date
Ray Slakinski
171c77475a iOS: the app's settings in Settings, not behind a shake; iPhone upright (#445)
The server address and the player token were set from alerts that
opened on shaking the phone, which nobody finds. Settings now has a This
device section in the app: the server, the player token and its
address, and a Make button that creates an API token and hands it
straight to the app's keychain. The app sends what it has at ready
(appSettings) and takes setServer, setToken and clearToken; a new
server rebuilds the web view as the alert did. The first-launch prompt
and the Cannot reach alert stay native, since there is no page then.

The iPhone is portrait only; the iPad keeps all four orientations, which
Split View and Slide Over need (#434 checked the iPad's layout).

Build 14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:39:54 -05:00
6d6bfe6e1d iOS CarPlay: the phone's bar takes up the car's episode; only playable shows (#442, #443, #444)
#442: a car's pick told the page `replaced`, and the page closed its player bar, so leaving the
car with the episode playing left nothing on the phone to pause it. And when the car launched the
app, the page loaded later and was never told anything played. Now the app sends `nowPlaying`
(the item, its place, playing or not) on a car pick, at the page's `ready`, and on coming
forward, and the page's bar takes it up and drives the phone's player. `replaced` is still
handled for builds before this one.

#443: Shows listed every feed with items, blogs included. Now only feeds with a downloaded file,
and an episode only with a downloaded audio file: /media/{id} answers 404 before the download.

#444: tests/native-bridge.ts read its failure count before the CarPlay checks, so those could
fail and still exit 0. The count is read last now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
2026-11-11 23:08:14 +00:00
Ray Slakinski
2bb7ed2109 iOS: CarPlay (#420)
A CarPlay scene beside the phone's: In Progress, Unread and Shows as
CPListTemplates from ipx's API, read with the player's credentials, and
a pick played through the same Playback, so it resumes from the saved
place, saves as it goes and marks read at 90%. Now Playing is the
system's template over Playback's now-playing info.

When the car starts a different episode the page is sent
{t:"replaced"} and closes its player bar. Left open, the bar would have
gone on as the old episode while the host's clock belonged to the new
one, saving that time and marking the wrong item read.

The badge's fetch moves into API.get, which CarPlay shares. Build 13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:10:34 -05:00
Ray Slakinski
f844cf349a iOS: Siri and Shortcuts, and a widget (#427, #428)
Siri: App Intents with phrases that need no setup: Resume, Pause,
Skip forward, Skip back, Set a sleep timer, How many unread. The
playback ones are AudioPlaybackIntents, run in the app and allowed to
start audio from the background. Playback is now one shared instance
(Playback.shared) so Siri and the widget reach it with no page loaded.
It remembers the last episode and its place (LastPlayed), so Resume
works with the app closed; the page, opened afterwards, does not know
about an episode started that way.

Widget: small and medium on the home screen, circular and rectangular
on the lock screen. It shows the unread count and the last episode,
with a Resume or Pause button (PlayPauseIntent, shared with Siri). It
draws what the app writes to the App Group group.net.sdf1.ipx. The app
asks for a redraw on play, pause, a new episode or a new count, not on
every position, since iOS rations redraws. Shared/ builds into both
targets, with WIDGET set in the widget's, where the player does not
exist.

ipx's push now carries content-available, which wakes the closed app
for a moment to give the widget the count the icon just got (#433).

Tested: unit tests for the snapshot and the remembered place, 19 in
all; in the simulator, playing an episode wrote the snapshot to the
App Group. Not testable there: Siri, and the widget drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:33:04 -05:00
Ray Slakinski
bd535851b0 The iOS app's icon count, pushed by ipx (#433)
Background refresh runs when iOS chooses, and for this app that has
been rarely, so the icon's unread count lagged until the app was
opened (#421). Now ipx sends the count:
- ipx: a push_devices table and POST/DELETE /api/push/devices. With
  [push] set to an APNs key, a task compares each phone's All
  Subscriptions unread count with the last sent, once a minute, and
  sends a changed one to Apple as a badge-only push: no alert, no
  sound. A token Apple says is gone is forgotten. The signing token is
  ES256 over the team and time, made again every forty minutes, inside
  Apple's limits.
- iOS: the aps-environment entitlement. The app registers for push
  once badge permission is given, each launch, and hands its token to
  the page.
- Page: registers the token with ipx as the person signed in, since
  the player's address (ipxapi) lets through only the player's paths,
  and removes it on signing out.

jsonwebtoken here is built without PEM support, so the .p8 key's
base64 is decoded in push.rs rather than turning on a feature and
changing Cargo.lock for one file.

cargo test -p ipx on macOS: 182 pass; the 3 that fail read /proc and
fail there without this change too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:47:28 -05:00
Ray Slakinski
e6414941ab iOS: the icon's count follows the page's (#421)
Reported: the unread count on the icon only changed when the app was
opened. Two reasons:
- Items read in the page never reached it, since only the player's own
  mark-read refreshed the badge.
- The background refresh that should bring new items runs when iOS
  chooses, which for this app has been rarely.

The page now sends All Subscriptions' count ({t:"unread", n}) each time
its list is drawn with a new one, so reading, and new items arriving
over the event stream while the app is open, set it at once. While an
episode plays the app is awake with the screen locked, so the player's
tick also re-checks every quarter of an hour.

With the app closed and nothing playing, the count still waits on iOS's
refresh. Push (#433) is what makes that exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:18:43 -05:00
Ray Slakinski
b455c1ec8c iOS: iPX opens with no connection (#435)
A WKWebView runs no service worker unless the app lists its domains
in WKAppBoundDomains and the web view limits itself to them. So the
Info.plist lists ipodderx.sdf1.net, with Cloudflare Access's
(rays-sdf1.cloudflareaccess.com) and Authentik's (authentik.sdf1.net)
hosts so signing in still works. For a server in that list the web
view is made app-bound and #389's worker runs: the page and its lists
open offline from the second launch, since the first only installs the
worker.

The costs:
- Navigation elsewhere is refused, and the failure looked like the
  server being down. decidePolicyFor sends such links to Safari.
- With the key present, WebKit cuts the bridge for any server not in
  the list. That server then gets the page with its own audio, not
  the phone's player.

The web view is made again when the server changes, since being
app-bound is fixed when it is made.

A debug build adds localhost through a build script, so the simulator
can test against a page served on the Mac; the release archive's plist
was checked to have only the three. Tested in the simulator: opened
twice online, the server stopped, relaunched, and the page came up from
the worker. With the bridge still up on the app-bound host, the
phone's player played.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:08:27 -05:00
Ray Slakinski
cf0dad7380 iOS: presses are felt (#430)
The page asks the app for a haptic ({t:"haptic", kind}) and the app
plays the phone's own. A light tap for play, pause, flag and pull to
refresh; the system's success for marking read, one item or all; its
warning with an error toast. A browser has no host to ask, and an
older app ignores the message, so there is nothing to gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:57:51 -05:00
Ray Slakinski
2e031763fa Player: a sleep timer, and AirPlay in the app (#429)
The sleep timer pauses after 5 minutes to an hour, fading out over the
last ten seconds. In the iOS app the phone's player keeps the time:
with the screen locked the web view is frozen, and a timer in the page
would not fire until the app was opened again. A browser plays on in a
background tab, so there the page keeps it.

The app says at ready what it does itself ({t:"host", can}), so a page
deployed ahead of the app falls back to its own timer and shows no
AirPlay button an older app would ignore. This deploy order problem
came up with #425.

AirPlay opens AVRoutePickerView by pressing its button from code; there
is no API to present it. On iOS 27 the picker opens on the button's
primary action, not touch-up, so that is what is sent, with touch-up
for versions that have nothing on it. The simulator has no AirPlay
devices and never showed the picker, so this is unverified until it
runs on a phone.

On a phone the player's one line left the title two letters wide once
the two controls were added, so the title takes the top line and the
controls flank play below it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:54:08 -05:00
Ray Slakinski
ecc248307e iOS: episodes kept on the phone play with no connection (#425)
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>
2026-11-08 14:40:01 -05:00
Ray Slakinski
05e522ac93 iOS: ask for the background refresh as the app leaves, too (#421)
Build 5. The badge's refresh was requested only when the app came
forward; iOS asks for it as the app goes to the background. The
request made on coming forward stays. The timing is still iOS's.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:31:21 -05:00
Ray Slakinski
8d4c70a571 Admin page: the top bar clears the status bar in the iOS app (#423)
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>
2026-11-08 14:18:55 -05:00
Ray Slakinski
3626a30b52 iOS: the player goes to ipxapi.sdf1.net with one API token; unread badge (#419, #421)
The Access service token plus API token pair was three values to enter
and Access in front of both. The player, the badge and CarPlay now send
the ipx API token alone to https://ipxapi.sdf1.net: a route of the
rays-unraid tunnel to the same ipx-web, with no Access application, held
to the paths the app uses by a custom firewall rule on the sdf1.net zone
(403 for anything without a bearer header or off the list: the page, the
login form, /api/login, ?token= sign-in, the admin API). docs/sso.md
says so, since it also says not to bypass Access for /api/*; this is
that warning's narrow version, and the rule is on Cloudflare, not here.

The unread badge (#421) shows All Subscriptions' count, as the page's
badge does: set when the app comes forward, after an item is read, and
by a background refresh iOS runs now and then. The test target had no
Info.plist and could never have run; it does now.

Build 4. Not yet run on a phone with the token set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:11:03 -05:00
Ray Slakinski
f6042e9b5f iOS: player tokens in the keychain, and a build that uploads (#419)
The player rode only on the web view's Cloudflare Access cookie, so it
stopped with "Sign in again" when that session ended, and CarPlay (#420),
which has no web view, would have had nothing at all. Credentials.swift
keeps an ipx API token and an Access service token in the keychain, and
the player sends them (Authorization: Bearer, CF-Access-Client-Id and
-Secret) on its audio, position, read-flag and artwork requests. Without
all three it uses the cookie as before. Shake, Player Tokens enters them.

project.yml gets what TestFlight needs: the team, a numeric version
(App Store Connect refuses 0.1.0-dev), upside-down portrait (an iPad
build fails validation without every orientation) and
ITSAppUsesNonExemptEncryption. Resources/Info.plist is generated by
XcodeGen and now ignored.

Not run on a phone: the header option on AVURLAsset is read by name, as
the SDK has no constant for it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:08:15 -05:00
a581e3fc88 An iOS app: the page in a web view, with the phone's own player (#418)
web/src/native.ts (#45) has handed playback to a host since September, and
there was no host in this repo: the earlier app, rays/ipodderx-app, grew native
lists beside the page and is left behind. This one is the page and a player:
a WKWebView of ipx-web, AVPlayer behind the bridge with the audio session to
play on locked, now playing and the remote commands (15 back, 30 forward,
scrubbing), and positions and read written from the player's own clock, with
DeadAir's copy named so the server reads the position on its clock.

Built with XcodeGen on a Mac; nothing here compiles the Swift. Its icon is
web/logo.svg at 1024.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 21:50:43 +00:00