Integrate radio playback with OS media session#150
Conversation
…Island) Radio now reports its playback state through audio_service so the OS shows station name, artwork, and play/pause controls on the lock screen and Dynamic Island. OS media controls correctly route to the radio player when active, and queue playback automatically exits radio mode.
📝 WalkthroughWalkthroughAdds explicit radio-mode support to KoelAudioHandler, delegating playback to a separate radio AudioPlayer, publishing station MediaItem and radio-specific playback state/controls, and synchronizing RadioPlayerProvider streams with the audio handler. Tests verify MediaItem mapping and radio controls. Changes
Sequence Diagram(s)sequenceDiagram
participant RadioPlayer as Radio Player
participant RadioProvider as Radio Provider
participant AudioHandler as KoelAudioHandler
participant OS as OS Media Session
RadioProvider->>AudioHandler: enterRadioMode(radioPlayer)
activate AudioHandler
AudioHandler->>AudioHandler: set _isRadioMode = true\nset _radioPlayer = radioPlayer
AudioHandler->>OS: mediaItem.add(station)
deactivate AudioHandler
RadioPlayer->>RadioProvider: playing/processing streams emit
RadioProvider->>AudioHandler: updateRadioPlaybackState(playing, processingState)
activate AudioHandler
AudioHandler->>OS: update playbackState (controls, playing, processingState)
deactivate AudioHandler
RadioProvider->>AudioHandler: play()/pause()
AudioHandler->>RadioPlayer: delegated play/pause
Note over RadioPlayer,OS: playback continues under radio mode
RadioProvider->>AudioHandler: stop() / on failure
activate AudioHandler
AudioHandler->>AudioHandler: exitRadioMode()\nclear _radioPlayer, reset playbackState
AudioHandler->>OS: clear/reset mediaItem/playbackState
deactivate AudioHandler
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly Related PRs
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@lib/audio_handler.dart`:
- Around line 72-84: updateRadioPlaybackState currently copies the existing
queue state and only patches controls/processingState/playing, which leaves
queue-specific metadata (systemActions, queueIndex, androidCompactActionIndices)
and exposes MediaControl.stop that KoelAudioHandler doesn't handle; fix by
constructing and publishing a radio-specific PlaybackState inside
updateRadioPlaybackState that clears queue-related fields (e.g., set queueIndex
to null/empty, systemActions to minimal set, androidCompactActionIndices to []),
and remove MediaControl.stop from the controls list for radio mode, or
alternatively implement a radio-specific override of KoelAudioHandler.stop() to
perform proper radio teardown; update the updateRadioPlaybackState function
and/or add a stop() override in KoelAudioHandler accordingly.
In `@lib/providers/radio_player_provider.dart`:
- Around line 30-37: The radio event listeners in RadioPlayerProvider are still
forwarding playback updates during queue handoff because active can be true
while audioHandler.isRadioMode is false; modify the handlers attached to
_player.playingStream and _player.processingStateStream (and any other places
calling updateRadioPlaybackState) to check both active and
audioHandler.isRadioMode before calling audioHandler.updateRadioPlaybackState so
events are ignored once isRadioMode has been flipped off (e.g., in
AudioHandler.stop()), ensuring _currentStation clearing cannot be overwritten by
trailing _player events.
In `@test/ui/screens/radio_stations_test.dart`:
- Around line 90-160: The tests currently construct MediaItem and controls
inline so they don't verify the real mapping logic; update the tests to call the
production code that produces those values: for MediaItem tests, invoke
RadioPlayerProvider.play(...) (or the helper that RadioPlayerProvider uses) and
read audioHandler.mediaItem (or extract and call the mapping function that
converts a RadioStation to a MediaItem) instead of building MediaItem literals;
for controls tests, call KoelAudioHandler.updateRadioPlaybackState(...) (or
extract the control-mapping helper used by that method) and assert against the
returned PlaybackState.controls. Locate RadioPlayerProvider.play,
KoelAudioHandler.updateRadioPlaybackState, or the mapping helpers in the diff
and either call them from the tests or extract a shared function (e.g.,
mapRadioStationToMediaItem / getRadioControls) and test that helper directly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2646ca97-5513-409b-bc8c-ac2b8269a590
📒 Files selected for processing (3)
lib/audio_handler.dartlib/providers/radio_player_provider.darttest/ui/screens/radio_stations_test.dart
- Construct fresh PlaybackState for radio instead of copyWith, clearing queue-specific fields (systemActions, queueIndex, etc.) - Remove MediaControl.stop from radio controls and add stop() override for proper radio teardown - Guard stream listeners with both active and isRadioMode to prevent trailing events during queue handoff - Extract mediaItemForStation() and radioControls() helpers and test them directly instead of inline literals
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@lib/audio_handler.dart`:
- Around line 296-299: _in _playAtIndex, exitRadioMode() is called before the
handoff to the player which can leave isRadioMode false even if the queue
handoff fails; move the exitRadioMode() call to after the successful
_setPlayerSource(...) and any subsequent seek/play calls (or after their awaited
completions) so radio mode is only exited when the new source is confirmed set
and playback started; use the existing _setPlayerSource, seek/play await paths
and only call exitRadioMode() on their success (and do not swallow errors so
failures keep isRadioMode true).
In `@lib/providers/radio_player_provider.dart`:
- Around line 69-75: The mediaItemForStation function currently uses
Uri.parse(station.logo!) which can throw FormatException and leave state
inconsistent after _currentStation is set and enterRadioMode() is called; change
it to use Uri.tryParse(station.logo ?? '') (or call Uri.tryParse(station.logo!)
defensively) so artUri is null for invalid or empty logos instead of
throwing—update MediaItem creation in mediaItemForStation to pass a nullable Uri
from Uri.tryParse(station.logo) and ensure any non-null assertion on
station.logo is removed to avoid exceptions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: cfab44c9-d256-4593-a78f-6ab23e8e1adc
📒 Files selected for processing (3)
lib/audio_handler.dartlib/providers/radio_player_provider.darttest/ui/screens/radio_stations_test.dart
🚧 Files skipped from review as they are similar to previous changes (1)
- test/ui/screens/radio_stations_test.dart
…ogos Move exitRadioMode() in _playAtIndex to after _setPlayerSource succeeds so radio mode stays active if the queue handoff fails. Use Uri.tryParse instead of Uri.parse for station logos to gracefully handle malformed URLs.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
lib/audio_handler.dart (2)
62-70: Consider adding defensive guard inenterRadioMode.The method overwrites
_radioPlayerunconditionally. While the provider reuses the same player instance, adding a guard or assertion could prevent subtle bugs if called incorrectly:void enterRadioMode(AudioPlayer radioPlayer) { assert(!_isRadioMode || _radioPlayer == radioPlayer, 'Already in radio mode with a different player'); _isRadioMode = true; _radioPlayer = radioPlayer; }This is optional since the current provider implementation always passes the same player.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lib/audio_handler.dart` around lines 62 - 70, Add a defensive guard in enterRadioMode to prevent overwriting _radioPlayer with a different instance when already in radio mode: check that either !_isRadioMode or that _radioPlayer == radioPlayer (use an assert for debug builds or throw a descriptive exception) before setting _isRadioMode = true and assigning _radioPlayer; this keeps enterRadioMode safe if it's ever called incorrectly while preserving current behavior when the same player is reused.
296-318: Improved positioning ofexitRadioMode(), but consider error handling.Moving
exitRadioMode()after_setPlayerSource()addresses the past review concern about premature exit. However, ifplay()at line 306 throws an exception afterexitRadioMode()is called, both radio and queue will be stopped with_errorCountincremented but no active playback.This may be acceptable behavior since the user explicitly initiated queue playback, but consider whether failed queue handoff should restore radio playback:
Alternative: restore radio on failure
try { await _setPlayerSource(mediaItem); + final wasRadioMode = _isRadioMode; + final radioPlayerRef = _radioPlayer; exitRadioMode(); _player.seek(Duration(seconds: position.toInt())); await play(); ... _errorCount = 0; } catch (e) { _errorCount++; + // Optionally restore radio mode on failure + // if (wasRadioMode && radioPlayerRef != null) { + // enterRadioMode(radioPlayerRef); + // } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lib/audio_handler.dart` around lines 296 - 318, _playAtIndex currently calls exitRadioMode() before awaiting play(); if play() throws the handler leaves radio stopped. Change flow so exitRadioMode() is performed only after a successful handoff: call _setPlayerSource(mediaItem), await play(), then call exitRadioMode() and reset _errorCount; in the catch block, implement a fallback that restarts radio playback (e.g., call an existing method like resumeRadioPlayback()/startRadio()/enterRadioMode() or add one) so that if play() fails the radio is restored and _errorCount is still incremented as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@lib/audio_handler.dart`:
- Around line 62-70: Add a defensive guard in enterRadioMode to prevent
overwriting _radioPlayer with a different instance when already in radio mode:
check that either !_isRadioMode or that _radioPlayer == radioPlayer (use an
assert for debug builds or throw a descriptive exception) before setting
_isRadioMode = true and assigning _radioPlayer; this keeps enterRadioMode safe
if it's ever called incorrectly while preserving current behavior when the same
player is reused.
- Around line 296-318: _playAtIndex currently calls exitRadioMode() before
awaiting play(); if play() throws the handler leaves radio stopped. Change flow
so exitRadioMode() is performed only after a successful handoff: call
_setPlayerSource(mediaItem), await play(), then call exitRadioMode() and reset
_errorCount; in the catch block, implement a fallback that restarts radio
playback (e.g., call an existing method like
resumeRadioPlayback()/startRadio()/enterRadioMode() or add one) so that if
play() fails the radio is restored and _errorCount is still incremented as
before.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: fa50afd6-f10c-4359-8589-33465ace04cc
📒 Files selected for processing (2)
lib/audio_handler.dartlib/providers/radio_player_provider.dart
🚧 Files skipped from review as they are similar to previous changes (1)
- lib/providers/radio_player_provider.dart
Summary
audio_serviceintegrationKoelAudioHandlergains a "radio mode" that suppresses queue player state updates and routes OS play/pause controls to the radio playerRadioPlayerProviderpushes aMediaItem(station name, artwork) and playback state throughaudioHandlerwhen radio is active_playAtIndex, and radio stops when queue starts playing (with a guard against radio's own state updates triggering the stop)Test plan
flutter testSummary by CodeRabbit
New Features
Bug Fixes / Improvements
Tests