Skip to content

Add native iOS listener app (v1) - #72

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

Add native iOS listener app (v1)#72
MMagTech wants to merge 27 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 27 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>
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