Skip to content

fix: synchronize iOS playback intent and system controls - #696

Closed
mvanhorn wants to merge 1 commit into
timusus:mainfrom
mvanhorn:fix/694-ios-playback-intent
Closed

mvanhorn wants to merge 1 commit into
timusus:mainfrom
mvanhorn:fix/694-ios-playback-intent

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

During a skip or while a song buffers, the lock screen and Control Center flip to the play button with a playback rate of 0, even though the track is about to play. When the audio session or the engine refuses to start, the player still holds a play intent, so a later queue move can start audio. After the media server resets, Now Playing is left blank whenever the reloaded song and playback state match what was already published. PlaybackSystemCoordinator published paused whenever the Kotlin state was anything other than Playing, and playWhenReady lived only as a private field on IosPlayerController, so a loading track that should play could not be told apart from one that should stay paused. A session refusal returned from EngineAudioPlayer without a state callback, and MusicPlaybackController.stayPaused dropped the report when setState saw the engine was already paused, so the Kotlin flag stayed true. On a media-services reset, rebuildEngine went through publish, which takes the throttled updatePlayback path for an unchanged item and never rewrites the Now Playing info the system had wiped.

Summary

playWhenReady is now a StateFlow that PlaybackSystemCoordinator collects. While a track is loading, the published rate and system playback state follow that flow, so a skip keeps the pause button and the current speed, and play, pause, and toggle update the button immediately while the state remains Loading. EngineAudioPlayer reports paused on the calling thread when the session refuses a load or a play, MusicPlaybackController repeats that paused report from stayPaused when the engine was already paused, and IosPlayerController clears the intent on those refusals without finishing the load early, ignores a paused report for a track a skip has already replaced, and keeps a play() made from the load's completion. After a media-services reset the replacement engine resumes only if playback was playing or still loading with intent, and publish(force:) writes the full Now Playing item again; an empty queue or a failure to build the engine leaves playback and the published info alone.

Test plan

  • New tests are in ios/Playback/Tests/S2PlaybackTests/MusicPlaybackControllerTests.swift, ios/S2Tests/EngineAudioPlayerTests.swift and ios/S2Tests/PlaybackSystemCoordinatorTests.swift. I did not run ./support/scripts/unit-test locally before this landed.

Fixes #694

@timusus

timusus commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Thanks! Landed on main as 264739a (cherry-picked with your authorship), with a follow-up in 01183aa: a refused play while the engine was already playing reported paused over audible audio, and three Kotlin test names with commas did not compile for Kotlin/Native. Fixes #694; follow-ups filed as #707 and #708.

@timusus timusus closed this Oct 1, 2026
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.

ios: lock screen play/pause can flicker during a skip or buffering

2 participants