Skip to content

Integrate radio playback with OS media session#150

Merged
phanan merged 3 commits into
masterfrom
feat/radio-media-session
Mar 29, 2026
Merged

Integrate radio playback with OS media session#150
phanan merged 3 commits into
masterfrom
feat/radio-media-session

Conversation

@phanan
Copy link
Copy Markdown
Member

@phanan phanan commented Mar 29, 2026

Summary

  • Radio playback now shows on the lock screen, Dynamic Island, and notification controls via audio_service integration
  • KoelAudioHandler gains a "radio mode" that suppresses queue player state updates and routes OS play/pause controls to the radio player
  • RadioPlayerProvider pushes a MediaItem (station name, artwork) and playback state through audioHandler when radio is active
  • Queue playback automatically exits radio mode via _playAtIndex, and radio stops when queue starts playing (with a guard against radio's own state updates triggering the stop)

Test plan

  • Play a radio station and verify it appears on the iOS lock screen / Dynamic Island with station name and artwork
  • Tap play/pause on the lock screen and verify it controls the radio stream
  • Pause radio, verify lock screen shows paused state
  • Play a song from the queue while radio is active — verify radio stops and song appears on lock screen
  • Run flutter test

Summary by CodeRabbit

  • New Features

    • Radio mode: dedicated radio playback state that integrates with system media controls, lets streams show station info (title, artist, artwork), and limits controls to play/pause.
  • Bug Fixes / Improvements

    • Improved sync between radio and queued playback: entering/exiting radio mode updates system state; stopping and failures reliably clear radio state.
  • Tests

    • Added tests validating media item mapping and radio control behavior.

…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.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 29, 2026

📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Audio handler (radio mode)
lib/audio_handler.dart
Introduced _isRadioMode and _radioPlayer, added isRadioMode getter, enterRadioMode(AudioPlayer), exitRadioMode(), updateRadioPlaybackState(...), static radioControls(...), and overridden stop(). Player event subscriptions and play/pause now delegate to _radioPlayer while in radio mode.
Radio player provider
lib/providers/radio_player_provider.dart
On radio start calls audioHandler.enterRadioMode(_player), publishes station MediaItem via audioHandler.mediaItem.add(...), maps ProcessingStateAudioProcessingState and forwards play/processing updates with audioHandler.updateRadioPlaybackState(...). Ensures exitRadioMode() is called on stop/failure. Added static MediaItem mediaItemForStation(RadioStation).
Tests (media session)
test/ui/screens/radio_stations_test.dart
Added tests for mediaItemForStation mapping, artwork URI behavior, and radio-mode MediaControls (play vs pause presence, skip/stop absence). Also added necessary imports for audio session logic.

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
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly Related PRs

  • Add radio station support #138: Related previous work introducing radio player/provider and UI; directly connected to these audio handler radio-mode APIs and provider integration.

Poem

🐰 I hop where signals bend and play,
I tuck the queue and let the station stay—
Controls switch gentle, the session hums bright,
A rabbit’s small cheer for audio night. 📻✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'Integrate radio playback with OS media session' directly and accurately describes the main objective: adding radio playback integration with the operating system's media session interface.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/radio-media-session

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between e1473ab and 774c0f6.

📒 Files selected for processing (3)
  • lib/audio_handler.dart
  • lib/providers/radio_player_provider.dart
  • test/ui/screens/radio_stations_test.dart

Comment thread lib/audio_handler.dart
Comment thread lib/providers/radio_player_provider.dart Outdated
Comment thread test/ui/screens/radio_stations_test.dart Outdated
- 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
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 774c0f6 and c321c2f.

📒 Files selected for processing (3)
  • lib/audio_handler.dart
  • lib/providers/radio_player_provider.dart
  • test/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

Comment thread lib/audio_handler.dart
Comment thread lib/providers/radio_player_provider.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.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
lib/audio_handler.dart (2)

62-70: Consider adding defensive guard in enterRadioMode.

The method overwrites _radioPlayer unconditionally. 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 of exitRadioMode(), but consider error handling.

Moving exitRadioMode() after _setPlayerSource() addresses the past review concern about premature exit. However, if play() at line 306 throws an exception after exitRadioMode() is called, both radio and queue will be stopped with _errorCount incremented 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

📥 Commits

Reviewing files that changed from the base of the PR and between c321c2f and 931cfe0.

📒 Files selected for processing (2)
  • lib/audio_handler.dart
  • lib/providers/radio_player_provider.dart
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/providers/radio_player_provider.dart

@phanan phanan merged commit 285d272 into master Mar 29, 2026
2 checks passed
@phanan phanan deleted the feat/radio-media-session branch March 29, 2026 13:16
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