Skip to content

fix(playback): radio after a cold iOS launch plays instead of dropping a healthy song (#311) - #327

Open
lolimmlost wants to merge 4 commits into
mainfrom
fix/ios-autoplay-unlock-311
Open

lolimmlost wants to merge 4 commits into
mainfrom
fix/ios-autoplay-unlock-311

Conversation

@lolimmlost

@lolimmlost lolimmlost commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Closes #311. Closes #256.

What happened

On a fresh iOS PWA launch, Liked Songs → Radio never played. Recovery then removed a healthy song (the Kreutzer sonata) from the queue as "unavailable", and the next one was silent too.

Testing the "transfer to a cold phone" path turned up three more problems, fixed here too.

Fixes

  1. Unlock audio inside the tap (iOS: radio started after a cold launch is blocked by autoplay policy, and stall recovery then deletes a healthy song as "unavailable" #311). startRadio calls unlockAudioForGesture() synchronously, before its ~4s /api/radio/seeded fetch. PlayerBar registers the unlock (src/lib/utils/audio-unlock.ts): it initialises and resumes the AudioContext, then play()+pause() each idle deck in the same tick (or a silent clip for an empty deck). The later play() is then allowed.
  2. NotAllowedError is not a stall (iOS: radio started after a cold launch is blocked by autoplay policy, and stall recovery then deletes a healthy song as "unavailable" #311). When the song loader's canplay play(), or a stall-recovery attempt, is refused by autoplay policy, playback pauses and a prompt appears. Recovery doesn't spend its 3 attempts on a play() that can't succeed without a tap.
  3. Recovery attempt 3 no longer deletes songs (iOS: radio started after a cold launch is blocked by autoplay policy, and stall recovery then deletes a healthy song as "unavailable" #311). It reloads with load() instead of src = '' first. The cleared src fired an error event that the loader's handler read as "unavailable". The loader also ignores errors from a deck with no src.
  4. The prompt is actionable. It stays up until something plays and has a Play button that is itself the tap (unlock, then play locally).
  5. Device picker button no longer depends on Music Assistant. It was the only way to open the picker on a locally playing device, and it was hidden whenever /api/speakers failed (MA down), so playback couldn't be handed to another device. It always shows now; the picker already hides its speaker section without MA.
  6. Remote pause no longer bounces back to playing (Cross-device: remote pause / transfer-away bounces back to playing (useMediaSession re-sync ignores user pause) #256). A store-only pause (remote command, takeover) re-runs useMediaSession's setup effect while the element is still playing, and its initial-setup path synced the store back to isPlaying=true. It now skips that sync within 3s of a user pause, the same window the other recovery paths use.

Tests

  • New: useStallRecovery.test.ts (4) and audio-unlock.test.ts (5). 3 of the recovery tests fail on the old code.
  • Full suite: 880 passed. Fix 6 has no unit test.

Device test (iOS PWA) — passed 2026-10-11 on 1f9441b

  • Cold launch → Liked Songs → Radio plays; no "unavailable" skip.
  • Desktop → transfer to a cold phone: play is blocked, the prompt shows, the song stays, and tapping Play starts it.
  • Pausing the phone from the desktop holds (prod log: isPlaying=false with no flip back), and transferring back resumes at the paused position.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LAQj7kfXYU9jy1L9ByWw8r

lolimmlost and others added 4 commits October 10, 2026 10:28
…g a healthy song (#311)

- Unlock audio inside the tap: startRadio calls unlockAudioForGesture()
  before its ~4s fetch; PlayerBar registers the unlock (init/resume the
  AudioContext, play+pause each idle deck) so the later play() is allowed.
- A play() refused by autoplay policy (NotAllowedError) is not a stall:
  the song loader and stall recovery pause and show "Tap play to start"
  instead of burning recovery attempts that can't succeed.
- Recovery attempt 3 reloads with load() instead of src = '' first. The
  cleared src fired an error event that the song loader read as
  "unavailable" and removed the song from the queue. The loader also
  ignores errors from a deck with no src.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAQj7kfXYU9jy1L9ByWw8r
…istant is down

The only way to open the device picker on a locally playing device was the
speaker button, shown only when /api/speakers reported MA configured. With MA
unreachable the button disappeared, and with it any way to hand playback to
another device (e.g. the iPhone). Always show it; the picker already hides
its speaker section when MA isn't available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAQj7kfXYU9jy1L9ByWw8r
…back starts (#311)

The 4s info toast vanished before it could be acted on. Keep it until
something plays, and make its Play button the tap itself (unlock the decks,
then play locally), so it works even when the UI is stuck in remote-control
mode (#256).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAQj7kfXYU9jy1L9ByWw8r
A store-only pause (remote_command from another device, takeover) changes
PlayerBar's remote-aware callbacks, re-running useMediaSession's setup effect
while the element is still playing. Its initial-setup path then synced the
store back to isPlaying=true. Skip that sync within 3s of a user pause, the
same window the other recovery paths already honour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAQj7kfXYU9jy1L9ByWw8r
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant