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