Add native iOS listener app (v1) - #72
Open
MMagTech wants to merge 35 commits into
Open
Conversation
Implements the app described in docs/IOS_APP_HANDOFF.md (#71): a SwiftUI radio tuner that talks to the public listener API and plays the Icecast stream through AVPlayer. The reason it exists is background resilience. The PWA schedules its stall recovery with setTimeout, which iOS never fires once it suspends the JS runtime at a track-transition underrun, so the station goes silent until the user foregrounds it. Here, an audio background mode keeps the process alive, and recovery runs on three independent signals: - AVPlayerItem stall/failure notifications - a 3s watchdog that catches the case AVPlayer does not report, where it still claims to be playing but the playhead has stopped moving - stream_epoch changes seen by the now-playing poll, which mean the broadcast restarted underneath us Each reconnect rebuilds the player item against a cache-busted URL and backs off 2s, 4s, 6s … capped at 15s, mirroring the web player, and takes a UIApplication background task so a reconnect that starts behind a locked screen gets to finish. Also included: station list on the web home page's 15s refresh, live now-playing (750ms foreground poll matching the web, 3s backgrounded where only the lock screen consumes it), up next / recently played, artist bio and knowledge facts, lock-screen and Control Center controls with skip and seek disabled since everyone hears the same broadcast, and a first-run server URL screen that validates against /api/health before saving. No third-party dependencies. Backend and web code are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Testing against a live server showed the app tearing the player down every
~11 seconds with "no audio arriving" — so audio never survived long enough
to be audible. Two separate mistakes in the liveness check:
- It used player.currentTime() as the progress signal. On an Icecast
stream the playhead does not advance the way a finite asset's does, so
a perfectly healthy connection looked permanently frozen.
- Switching to the access log fixed most of it but still fired about
once a minute, because numberOfBytesTransferred is per-event: reading
only the newest entry makes the running total appear to drop each time
AVPlayer opens a new event.
The marker is now the sum of bytes transferred across all access-log
events, which is monotonic while data is arriving, falling back to the
buffered range end before the log has entries. Sampling moved to 2s with a
3-sample threshold, so a genuine silent stall is caught in ~6s.
App icon is generated from the PWA's icon-512.png, upscaled to 1024pt and
flattened onto the manifest's #111113 background since iOS app icons must
be opaque.
DEVELOPMENT_TEAM is set for device builds. It isn't a secret (team IDs
ship inside every signed app), but contributors building on their own
account will need to change it or override it in Xcode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app connected to the stream and rendered a full now-playing UI while
producing no sound at all, on both the simulator and a device. Two causes,
found by logging AVPlayer's actual state each watchdog tick:
playhead=0.0 marker=17 rate=0.0 tcs=2 status=1 keepUp=1 empty=0
Buffer healthy, item ready, rate pinned at zero.
1. automaticallyWaitsToMinimizeStalling was set to false. That was meant to
start playback promptly, but it also means a play() issued before the
item reaches .readyToPlay is dropped and never retried. openStream calls
play() immediately, so the player sat at rate 0 forever, buffering
happily and playing nothing. It is back to its default.
2. Playback used the direct Icecast stream_url. Before opening a real
playback connection, iOS sniffs a media resource with a small
`Range: bytes=0-1` probe; Icecast ignores the range and answers with an
endless 200, so the probe never terminates. The backend already solves
this on /api/stations/{slug}/listen, which answers the probe with a
bounded 206 (see _probe_range_end in routers/stations.py) — that
endpoint is now what AVPlayer gets. Note this contradicts the handoff
doc's advice to prefer stream_url, which is wrong for iOS.
Verified against a live server: 78s of continuous playback with the
playhead advancing in real time and zero reconnects, versus a teardown
every ~57s before. That ~57s cycle was a symptom of the player never
starting, not the proxy timeout it looked like.
The watchdog now logs the playhead alongside the byte marker when it
fires, since a frozen marker with a moving playhead (stream died) and both
frozen at zero (player never started) need very different fixes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tapping a row used to tune the station and navigate in one gesture, so
there was no way to look at what else was on without hijacking playback.
Rows now carry two independent controls: the artwork/title area opens the
station's page, and a trailing button plays it in place without leaving
the list. Opening a station starts nothing — only its play button does.
Station pages therefore have to work for a station you are not listening
to. StationDetailView takes any StationSummary and reads from the player
when that station happens to be the tuned one, falling back to its own
StationDetailLoader otherwise. That loader polls at 5s rather than the
player's 750ms: nothing on a browsed page drives a lock screen, and the
tuned station's fast poll must not be disturbed by whatever else is being
looked at. Playback state ("Live", "Reconnecting…") is shown only for the
tuned station; other pages just say whether they are on air.
NowPlayingView is gone — it assumed the station on screen was always the
one playing, which is exactly the assumption being removed here.
Verified against a live server: playing Concrete Poetry from the list then
opening Skate Park left concrete-poetry at 1 listener and
skate-park-anthems at 0, and pressing play there moved the listener across
cleanly with no reconnects.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The server's artist_bio_enabled setting governs every listener on every client, which is the wrong granularity for "I want this on a desktop screen but not on my phone". Settings now carries two local toggles, defaulting on, that decide whether to render the material the server did send. They are deliberately display-only: the server toggle still decides whether the data is sent at all, and turning these off does not reduce the payload. That tradeoff is fine here — the point is what is on screen, not bandwidth. Track trivia gets its own toggle rather than riding along with artist info, since the knowledge block is separately feature-flagged server-side and someone may reasonably want one and not the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the web player, which puts a small circular ⓘ on the artwork and
opens the facts from there rather than laying them out in the page. The
badge only renders when the current track actually has facts, so its
presence is itself the signal that there is something to read.
This also fixes the layout problem the inline version created: three
paragraphs of grey body text sat between the transport and "Up next",
pushing the queue below the fold and competing with the artist bio for the
same visual weight. Both of those sections are gone from the page now.
The sheet shows each fact with its category and its source link. Sources
were previously dropped entirely, which mattered more than it looks: these
facts are LLM-generated from web search and make very specific claims
("Maynard James Keenan's 42nd birthday", "released December 18, 2007"), so
the citation is the reader's only way to check one.
Two deliberate departures from the web player:
- No 15s auto-rotation. That suits a fixed desktop panel you glance at;
on a phone the reader is holding the text, and having it advance
mid-sentence is worse than showing all of it.
- The facts travel with the presentation via .sheet(item:) rather than
being snapshotted into separate @State and shown with
.sheet(isPresented:). The latter renders an empty sheet — the body
builds before the snapshot lands, so the title appears and the facts
do not.
Verified against a local stub serving a known knowledge block, since no
station on the live server had facts across ~5 minutes of polling all 21.
Badge appears on the cover, opens, and renders all three facts with
categories and working Wikipedia links.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same slide-up treatment as track trivia. The artist's name under the track title becomes the target rather than a second glyph on the artwork: it is self-labelling, so there is nothing to guess, and the cover keeps a single badge instead of accumulating a row of unlabelled icons. Bios run to a few thousand characters — T.I. is 2.9 KB — so inline they pushed "Up next" off the screen on essentially every track. The station page is now artwork, title/artist, transport, queue, with both extras a tap away. Surfaces artist_bio_url as a "Read more on Last.fm" link, which was being decoded and then dropped, the same oversight as the trivia sources. Both sheets share one .sheet(item:) driven by an enum rather than two stacked .sheet modifiers, which is unreliable on a single view. Verified against the live server: T.I. on Concrete Poetry, tinted name with an ⓘ, sheet opens with the full bio, scrolls, expands to the large detent, and the Last.fm link resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Appearance: the app hard-coded .dark, so it ignored the system setting entirely. Settings now carries System/Light/Dark, defaulting to System. Light mode had never been exercised before this — the adaptive colours hold up, since everything was already .bar/.quaternary/.secondary/tint rather than fixed values. Mini player: long titles were truncated in a bar that is permanently on screen and has no detail view of its own, so there was nowhere to read the rest. MarqueeText slides to reveal the tail, slowly, pausing at each end. Deliberately not used anywhere else: list rows and station pages have room to wrap or a screen to open, and self-moving text is a cost there. The scrolling copy sits in an overlay behind a hidden, truncating copy that does the layout. Letting the fixedSize text participate in layout directly propagates the full string width into the enclosing HStack, which shoves the artwork off the leading edge of the bar. Lock screen: ⏮/⏭ were disabled on the grounds that live radio has nothing to skip to. They now tune to the previous/next station, wrapping at both ends, which is the closest thing a radio has to a skip button. The station list screen keeps the player's copy of the ordering in sync so the buttons follow the same order shown on screen. Seek and scrub stay disabled — a live stream still has no timeline. Verified in the simulator: System follows the host appearance, and the marquee reveals the tail of a 57-character title in the mini player while the same title stays truncated in the list row above it. The lock-screen buttons are not verified — the simulator does not render the now-playing widget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Favourites are stored on the device and never sent anywhere. The API already sorts `featured` stations first, but that is the operator's choice for every listener; this is one person's, on one phone, so the two are kept separate rather than one silently overriding the other. Favourited stations move into their own "Favorites" section above "All stations", and each group keeps the server's ordering inside itself — so featured/sort_order still decides what leads within a group. With no favourites the list renders exactly as before, without empty section headers. Toggling is a leading swipe, with a long-press menu as a second route. The row already carries two targets — tap to open, button to play — and a third control would crowd both. The lock screen's ⏮/⏭ now follow this same order, favourites included, so skipping matches the list you can actually see. Verified in the simulator: swipe reveals Favorite, the station moves to a Favorites section, swiping it again offers Unfavorite, and the slug persists to UserDefaults. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hearting a track is admin-only in the web player and was simply absent here. Settings now takes optional admin credentials; without them the app behaves exactly as before, which is the point — listening needs no account and someone who never signs in should never notice this exists. Sign-in goes through POST /api/admin/login for the session cookie rather than sending HTTP Basic on each request. require_admin would accept Basic on the write endpoint, but the station poll decides whether to include `hearted` by looking for a session cookie specifically (admin_user_from_request), so Basic alone would let you heart a track while never being able to see that it worked. Credentials go in the Keychain, not UserDefaults where the display preferences live — that is a plist in the app container and ends up in file-system backups. Stored per host, so pointing the app at another server doesn't reuse credentials meant for the previous one, and signing out drops the cookie immediately rather than waiting out its 7-day expiry. `hearted` is nil for anyone not signed in, so it doubles as the gate: no sign-in, no heart state, no button. The toggle updates optimistically because the poll that would confirm it is up to 5s away when browsing, and falls back to server truth if the write fails. Verified: signed out, the API omits `hearted` and no button renders; wrong credentials report "Wrong username or password" and persist nothing. A successful sign-in and an actual heart toggle are unverified — they need real admin credentials, which are the operator's to enter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tapping the mini player pushed onto the navigation stack, so the station slid in from the trailing edge — the right gesture for a list row, the wrong one for a bar pinned to the bottom of the screen. It now rises from the bar it was tapped on and can be pulled back down, with a chevron-down close rather than a back arrow. Tapping a row in the list still pushes, which is what that gesture should do. Only the mini player changes. Settings and the now-playing sheet share one .sheet modifier driven by an enum. They were about to be two stacked .sheet modifiers on the same view, which is the arrangement that silently misbehaves — the same trap that made the trivia sheet come up empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sits alongside Favorite on the same leading swipe, so hearting what a station is playing doesn't require opening it first. Absent entirely when signed out, like the heart on the station page. Deliberately one-way. GET /api/stations never reports heart state — only the station detail calls attach_operator_heart — so a swipe action here cannot know whether the track is already hearted, and a control that might be hearting or un-hearting depending on state the user can't see is worse than one that only ever hearts. Un-hearting stays on the station page, which does know. Favorite remains the first action, so a full swipe still favourites rather than silently doing the new thing. For feedback, a hearted track shows a small heart against its title until the station moves on. That is tracked by item id rather than station, so it clears itself on the next track instead of implying the new one is hearted too. Verified signed out: only Favorite appears. The heart action itself needs operator credentials to exercise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The title and subtitle sat next to a Spacer. Both are flexible, so the HStack split the slack between them rather than giving it all to the text — the text column ended up narrower than the bar, and titles that would have fit were scrolling for no reason. The column now claims the remaining width itself and the Spacer is gone, so the play button still sits at the trailing edge and the marquee only engages when a title genuinely doesn't fit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything on the screen sat at a uniform 20pt from everything else, so
nothing read as belonging together: four separate things, evenly spaced,
with no grouping to say which was which.
- Title and artist tighten to 4pt so they read as one unit.
- The status line moves below the transport. Between the artist and the
play button it separated the metadata from its own heading; underneath
it annotates the control it describes.
- The play button is filled with the accent colour. It was .quaternary
grey — the faintest control on a screen whose entire purpose is playing
the station.
- A hairline divider separates now-playing from the queue, which
previously just continued the same vertical rhythm.
Spacing is deliberately uneven now (24 / 24 / 14 / 26) rather than one
value repeated, since that evenness was the problem.
The heart still balances against an invisible spacer so the play button
stays centred whether or not an operator is signed in.
Isolated to one commit so it can be reverted whole if it doesn't land.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Whitespace and a hairline divider grouped the block correctly but read as no change at all — the separation was legible as design intent and not as a boundary. An actual edge is what says these things belong together and the queue below doesn't. Title, artist, transport and status now sit in a filled rounded container with a hairline border, replacing the divider. Internal hierarchy from the previous commit is unchanged; only the enclosure is new. Uses secondarySystemBackground and a primary-opacity border so it tracks light and dark rather than needing a second set of values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The card was ~200pt tall to hold two lines of text and one button, stacked down the centre with the full width unused. Together with the 320pt cover that pushed the queue almost entirely below the fold — one item visible where there is now room for three. Metadata moves left, the heart joins it on the right of the same row, and the transport becomes a full-width button beneath. The button is labelled rather than a glyph because "play" is the wrong verb here: on live radio it means tune in to what is already happening, not resume, and no icon carries that. It reads "Listen live", and "Stop" once tuned in — an icon alone would have to imply both. Titles wrap to two lines rather than scrolling. The mini player marquees because it is a fixed one-line bar with no detail view to open; this screen has the room and nothing else on it moves. Two lines caps how far a long title can grow the card. Artist drops to subheadline so it supports the title instead of competing with it, now that both are left-aligned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The accent was #F9AF2D, a value invented while scaffolding the asset
catalog before I knew the theme system existed. It coincidentally matched
the backend's DEFAULT_THEME name ("amber") but not this install, which is
set to violet — so the app was amber while the web player and the app's own
icon, generated from the PWA artwork, were both violet.
Settings now offers the same six accents as the web player, using the same
hex values (themes.css [data-accent]), defaulting to "Match server" — which
reads default_theme from /api/health and follows it. A theme on this backend
is only an accent, not a palette, so matching it is this one value.
The accent applies once at the root via .tint, which propagates. The two
places that hardcoded Color.accentColor now use .tint, since Color.accentColor
reads the asset catalog and would have ignored the setting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
iOS gives apps no way to enumerate or select Bluetooth devices directly, so AVRoutePickerView is the sanctioned route — it lists Bluetooth speakers, headphones and AirPlay destinations together. Previously this was reachable only from Control Center, even though the app already sets allowsExternalPlayback and the .longFormAudio route policy. prioritizesVideoDevices is off: this is an audio-only app, and leaving it on biases the picker toward video destinations and hides some speakers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plain-style list headers pin to the top of the viewport while rows scroll underneath them. The Favorites and All stations headers had no background, so a row passing behind a header drew in the same pixels as the header text — artwork and two sets of titles overlapping into something unreadable. The headers are now opaque and span the full width, so rows disappear behind them the way the pinning behaviour assumes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The phone stays the remote: the Chromecast fetches the Icecast stream from the server itself, and the app still drives which station plays. Browsing, now-playing polling, favourites and hearts are untouched, since none of them depend on where the audio comes out. While a session is live, openStream hands the stream URL and metadata to the receiver instead of building an AVPlayerItem, and the stall watchdog and reconnect logic stand down — they are AVPlayer's, and there is no AVPlayer. Recovery becomes the receiver's problem. Starting or ending a session moves the audio automatically rather than making the listener re-press play. The SDK is fetched by ios/Vendor/fetch-cast-sdk.sh rather than committed: at 29 MB it would sit in this public repo's history forever. xcodebuild still works unchanged — no workspace, no Ruby toolchain. Took the dynamic framework despite Google's docs steering you to the static one. The static library references GTMSessionFetcher without defining it, so it needs that vendored separately — the manual-setup page doesn't mention this. The dynamic framework compiles GTMSessionFetcher and Protobuf in with hidden visibility, verified with nm, so it stands alone. It is also 29 MB against 141 MB. The docs' claim that the dynamic build needs Protobuf >= 3.13 separately does not hold for 4.8.6. The cast button only appears once a receiver is actually on the network, rather than offering a button that opens an empty list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Giving the pinned headers an opaque background didn't fix the overlap: rows win the z-order against a plain-style section header, so the header text and whatever row is passing behind it still drew into the same pixels. They are ordinary rows now rather than Section headers, so they scroll with the content and cannot overlap anything. For a list this short, sticky headers were buying nothing to begin with — the previous commit tried to paint over a problem that didn't need to exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Discovery never ran, so no Chromecast was ever found and iOS never even asked for local network permission — the app appeared in Settings with no Local Network toggle at all, because nothing had touched the network. startDiscoveryAfterFirstTapOnCastButton defaults to true: the SDK defers discovery until a cast button is tapped, so the permission prompt lands on a deliberate user action. That deadlocks against showing the button only once a device has been found — no button, so no scan, so no devices, so no button, forever. Discovery now starts at launch and scans actively rather than passively; passive scans are lower power but slow enough to notice a device that it reads as casting being broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The heart sat between the title and the output controls, grouping it with them. It is an action on the track, not on where the track plays, so the cast and route buttons now group together and the heart sits apart at the trailing edge. Also wires MPRemoteCommandCenter's likeCommand, gated on the same signal as the on-screen button: `hearted` is only sent to a signed-in operator, so its presence decides whether the heart is offered at all. Whether iOS surfaces this on the Lock Screen is iOS's decision — it is reliably shown on CarPlay and the Watch and inconsistently on the phone — but it costs little and it is the only route to a heart in the *system* now-playing UI. A guaranteed one would mean a Live Activity with its own widget extension. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The title sized to its content, so a two-line track followed by a one-line one changed the card's height and shoved the whole queue below it up or down. That happens several times an hour on a screen nobody is touching. The title now reserves two lines whether it needs them or not, so the card is the same height for every track. The cost is a gap under a one-line title where the reserved line sits. A single line with a marquee would avoid both the jump and the gap, at the price of text that moves on a screen where nothing else does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wrapping changed the card's height whenever a one-line track followed a two-line one, shoving the queue below it around several times an hour. Reserving two lines fixed the movement but left a gap under every short title, which is most of them. A single scrolling line is the same height for every track and never padded, and it matches the mini player. This reverses the earlier call that this screen should wrap because it has the room and nothing else on it moves. That held in isolation; it stopped holding once the height changes it caused turned out to be the more annoying problem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cast, AirPlay and heart glyphs come from three different sources — the Cast SDK, AVKit and SF Symbols — and were being given three different frame sizes on top of that. The result was three sizes, three stroke weights and three vertical positions in one row. They now share one box size, the heart is weighted to match what the two UIKit glyphs render at rather than SF Symbols' lighter default, and the group is nudged up so their centres sit on the title's centre instead of the top of its line box. Verified for the cast and route buttons; the heart needs an operator sign-in to appear, which this simulator doesn't have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the web player's tuning bed, on a Settings toggle, defaulting on. The noise is synthesised rather than shipped as an asset — band-passed white noise at a randomised centre frequency, so no two switches sound the same and there's no audio file in the bundle. It deliberately doesn't duck the live stream the way the web player has to. There the audio element keeps playing the old station through the switch, so its volume must be pulled down and restored, and the restore can be throttled while backgrounded — tuning-static.js carries a workaround for playback stranded at ~18% volume because of exactly that. Tuning here tears the old AVPlayer down first, so there is already a gap to fill and nothing to duck. Skipped while casting, where the audio is coming out of a speaker this wouldn't reach, and on the first tune-in, where there is no switch to cover. A failure to start the engine is swallowed: this is decoration and must never be why a station doesn't tune. Verified that switching stations still moves the listener across with zero reconnects and no audio-engine errors — that the two engines coexist was the risk, not the sound itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
For the phone propped on a desk while a station plays: cover art large on a
blurred version of itself, track, artist, station, and controls that fade
after a few seconds. The screen is kept awake, which is the actual point —
iOS would otherwise sleep half a minute into an album you are looking at.
Entered from a ⤢ badge mirroring the trivia one in the opposite corner, or
by tapping the artwork. Left by the close button, a downward drag, or a tap
to bring the controls back first.
No new fetching: it takes values from the station page, which is still
polling behind it, and a fullScreenCover's content re-evaluates when its
parent's state changes. Nothing here touches the audio pipeline.
isIdleTimerDisabled is cleared on scenePhase change as well as onDisappear.
A flag that silently keeps someone's screen awake is a worse bug than
anything else on this screen, and one dismissal path failing shouldn't be
enough to leak it.
Three layout mistakes worth recording, all mine and all found on screen
rather than by reading the code:
- aspectRatio after frame let the artwork grow to whatever height the
spacers left free, which on an otherwise empty screen was most of it.
- The play button was invisible: .tint(.white) was applied after
.background(.tint, in: Circle()), so the circle resolved its fill from
the environment that had just been overridden — white on white.
- Centring the controls by balancing an HStack only works when every
control is present, and the heart is absent unless signed in. The play
button is centred in a ZStack instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…a fade Skip: ⏮/⏭ flank the play button, doing what the lock screen's already do. Once opened on the tuned station the screen follows the player rather than the station it was opened with — otherwise skipping would change what is playing while this kept showing the old one. Hidden when not following, or when there is only one station: nothing to skip to. Marquee width, twice: the station card put a Spacer beside the title, and the display view let the title size to its content. Both leave the text a box narrower than the space available, so titles scrolled that would have fit — the same mistake already fixed once in the mini player. Flexible text and a Spacer in one HStack divide the slack between them; the text has to claim it outright. Marquee edges now fade rather than ending on a hard clip, applied inside MarqueeText so the mini player gets it too. Only when the text actually overflows — masking a short title would dim its own first and last letters for nothing. The full-screen badge is centred on the artwork's corner rather than inset from it, so it reads as attached to the artwork. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The full-screen badge straddled the top-right corner, so half of it hung outside the artwork and the top edge clipped against the screen. The trivia badge was in the bottom-right, which put the two of them down one side rather than reading as a pair. They now sit inside the cover on opposite diagonal corners — full screen top right, trivia bottom left — sharing one inset constant so neither can drift to its own margin later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The badges sat much further from the side edges than from the top, which read as sloppy padding but wasn't padding at all. The artwork applied .frame(maxWidth: 320) before .aspectRatio(1, .fit). In that order the aspectRatio wrapper sizes itself to the full proposed width — 362pt here — while the frame inside caps the image at 320. Overlays align to the wrapper, so both badges were positioned against a box ~20pt wider than the cover anyone can see, leaving that much dead space on the trailing side. Swapping the order makes the reported size the drawn size. Inset tightened to 8pt now that it means what it says. Same ordering mistake as the display view's artwork, fixed there earlier — worth remembering that .aspectRatio must come first when anything is going to be positioned against the result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rows now carry the two swipe directions the app needs: leading for favourite and heart, trailing for full screen. Getting to display mode previously meant opening the station first. The presented station is resolved against the store rather than shown as captured on the swipe, so a station you aren't listening to still updates as the list refreshes instead of freezing at whatever was playing when you swiped. The tuned station already follows the player. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening display mode from the list on a station you weren't listening to left it permanently without ⏮/⏭ or the heart. All three are gated on `following`, which was set once on appear from `isTuned` — false in that case, and never re-evaluated. Pressing play inside the screen tuned the station but the controls stayed hidden regardless. `following` now also flips when the player tunes to the station this screen was opened on, so play hands over to the same follow-the-player behaviour the tuned-station path already had. The gate itself is right: skipping stations and hearting act on the player, and offering them for a station nobody is listening to would do nothing. Verified by state rather than by eye — pressing play inside display mode moves the listener to that station, which is the condition the hand-over keys off. The controls' auto-hide made catching them mid-screenshot unreliable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Losing connectivity looked identical to a stream hiccup: the same backoff, the same "Reconnecting…", and a counter climbing into the teens with no explanation. On a train that reads as a broken app. An NWPathMonitor now separates the two. With no path there is nothing to retry against, so the player tears down and holds at "Waiting for a connection" instead of burning attempts. Regaining a path reconnects immediately rather than waiting out a backoff that had already stretched to 15s — for radio that matters more than it sounds: walk back into signal and it should come back on its own, not half a minute later. Nothing gives up. Live radio that stops trying is worse than one that keeps saying it's trying, so the retry continues indefinitely; only the wording changes, to "Can't reach the station — still trying" after a few attempts. The escalating counter is gone — it measured our persistence, not anything the listener needed. Polling also backs off while offline rather than failing every five seconds against an interface that isn't there. Verified that normal playback is unaffected — 70s clean, no spurious transitions. The offline path itself needs airplane mode on a device: the simulator takes its connectivity from the Mac, and the honest way to test this is the way a listener will hit it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A short track following a long one scrolled with room to spare. The two width measurements land in separate passes, so for one frame the new title's box was compared against the *previous* title's text width: '4 My People' text=189 box=115 overflow=74 <- scrolls '4 My People' text=115 <- corrected, too late 74pt of phantom overflow was enough to start a repeatForever animation, and correcting the measurement afterwards didn't stop it. Overflow is now zero until both measurements have arrived for the current text, and a text change discards the previous pair outright. The guard alone wasn't enough — stale values are both non-zero, so they look valid. Found by logging the widths rather than reading the layout: the code looked correct, and the bug was in when the numbers arrived, not what they were. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Starts the native iOS app described in
docs/IOS_APP_HANDOFF.md(#71). Everything lands underios/— backend and web code are untouched.Why
The PWA schedules its stall recovery with
setTimeout. iOS suspends the JS runtime the moment audio stops — including a brief underrun at a track transition — so the recovery timer never fires and the station stays silent until the user foregrounds the app.This app makes recovery the OS's problem instead. An
audiobackground mode keeps the process alive with the screen locked, and three independent signals trigger a re-tune:AVPlayerItemstall / failed-to-play-to-end notificationsAVPlayerdoesn't report — it still claims to be playing, but the playhead has stopped moving because no audio is arrivingstream_epochchanging in the now-playing poll, meaning the broadcast restarted and our connection is dead whether or notAVPlayerhas noticedEach attempt rebuilds the player item against a cache-busted stream URL and backs off 2s, 4s, 6s … capped at 15s (mirroring
web/static/app.js), holding aUIApplicationbackground task so a reconnect that begins behind a locked screen gets to finish.What's in v1
ios/, builds and runsGET /api/stationsAVPlayeronstream_urlAlso: up next / recently played, artist bio and knowledge facts, on-air badges and listener counts, AirPlay, and route-change handling (headphones out stops playback rather than switching to the speaker).
Notes
MPRemoteCommandCenter's skip/seek commands are explicitly disabled.cover_urlarrives as a same-origin path whilestream_url/artwork_urlare absolute, so all image and stream URLs resolve throughAlchemyAPI.resolve.Info.plistshipsNSAllowsArbitraryLoads, since self-hosted servers are usually plain HTTP on a LAN or VPN address.fm.alchemy.AlchemyFM); set your own team and identifier for device builds.Verification
Builds clean and runs on the iPhone 17 Pro simulator (Xcode 26.6). First-run setup and its failure path are verified end to end — an unreachable address reports "Could not connect to the server" rather than spinning.
Station list, live playback, and reconnect behaviour still need a run against a real server, and background/lock-screen behaviour has to be confirmed on a physical iPhone as the handoff doc calls out.
Merge #71 first if you want the handoff doc to land ahead of the code.
🤖 Generated with Claude Code