Skip to content

Add native iOS listener app (v1) - #72

Open
MMagTech wants to merge 35 commits into
masterfrom
ios/native-app-v1
Open

Add native iOS listener app (v1)#72
MMagTech wants to merge 35 commits into
masterfrom
ios/native-app-v1

Conversation

@MMagTech

@MMagTech MMagTech commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Starts the native iOS app described in docs/IOS_APP_HANDOFF.md (#71). Everything lands under ios/ — 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 audio background mode keeps the process alive with the screen locked, and three independent signals trigger a re-tune:

  • AVPlayerItem stall / failed-to-play-to-end notifications
  • a 3s watchdog for the failure AVPlayer doesn't report — it still claims to be playing, but the playhead has stopped moving because no audio is arriving
  • stream_epoch changing in the now-playing poll, meaning the broadcast restarted and our connection is dead whether or not AVPlayer has noticed

Each 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 a UIApplication background task so a reconnect that begins behind a locked screen gets to finish.

What's in v1

Milestone (handoff doc) Status
1. Xcode project in ios/, builds and runs
2. Station list from GET /api/stations
3. Tune in — AVPlayer on stream_url
4. Now-playing screen with polled metadata
5. Background audio, lock screen, interruptions
6. Stall/failure recovery with backoff
7. Server URL settings screen

Also: 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

  • SwiftUI + Swift Concurrency, iOS 17 target, no third-party dependencies.
  • Listener experience only — no admin or operator features, per the handoff doc.
  • No skip, seek, or pause-and-resume anywhere: "play" always means re-tune to live, and MPRemoteCommandCenter's skip/seek commands are explicitly disabled.
  • cover_url arrives as a same-origin path while stream_url / artwork_url are absolute, so all image and stream URLs resolve through AlchemyAPI.resolve.
  • Now-playing polls at 750ms in the foreground to match the web player, backing off to 3s when backgrounded — there the only consumer is the lock screen, which doesn't need sub-second updates.
  • Info.plist ships NSAllowsArbitraryLoads, since self-hosted servers are usually plain HTTP on a LAN or VPN address.
  • Bundle ID is a placeholder (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

Marcus Magnant and others added 30 commits August 2, 2026 06:39
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>
MMagTech and others added 5 commits August 2, 2026 16:39
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant