Skip to content

fix: UPnP remote playback — protocol correctness, session reliability, and UI#128

Open
tbrackbill wants to merge 3 commits intodddevid:masterfrom
tbrackbill:fix/upnp-remote-playback
Open

fix: UPnP remote playback — protocol correctness, session reliability, and UI#128
tbrackbill wants to merge 3 commits intodddevid:masterfrom
tbrackbill:fix/upnp-remote-playback

Conversation

@tbrackbill
Copy link
Copy Markdown

@tbrackbill tbrackbill commented Apr 15, 2026

Two-commit fix for UPnP remote playback issues found during testing against moode/upmpdcli.

Commit 1 — UPnP service layer

  • Correct protocolInfo MIME type in DIDL-Lite from the wildcard http-get:*:*:* to http-get:*:<mime>:* — strict renderers with "check metadata" enabled (moode, upmpdcli) reject the wildcard and refuse to play
  • Add Connection: close request header so Dio never reuses a TCP socket the renderer silently closed after its keepalive timeout; the dead-socket SocketException was being swallowed, freezing the progress bar for the rest of the song
  • Reduce connect/receive timeout 5 s → 3 s for faster failure detection
  • Track consecutive poll failures with a counter; reset on success

Commit 2 — App layer (provider + UI + Android MediaSession)

  • Gate all audio-focus and BECOMING_NOISY callbacks on !isRemotePlayback so Android screen-off no longer pauses the UPnP renderer
  • Replace _audioPlayer.positionStream with a StreamController.broadcast() fed by both the local player and the UPnP poll, fixing the frozen progress bar in remote mode
  • Guard local player stream listeners (position, state, duration) so idle local-player values don't overwrite UPnP-managed state
  • Reset _upnpWasPlaying before sending Stop to prevent a mid-load poll from misreading STOPPED as a natural track end
  • Remove dur > Duration.zero from track-end detection — moode returns 0:00:00 from GetPositionInfo when stopped, which silently blocked queue advancement
  • Only call updateRemoteVolume() when the polled volume actually changed — prevents the audible snap-back when pressing physical volume buttons
  • Volume slider wraps in Consumer<PlayerProvider> and routes gestures through provider.setVolume() in remote mode
  • Fix onAdjustVolume in MusicService.kt to use getCurrentVolume() instead of the captured constructor val so relative adjustments accumulate correctly

With these fixes, UPnP casting works as expected in daily use of a GrapheneOS + Navidrome + moode setup.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Remote playback status indicator for enhanced user awareness.
    • MIME type support for improved compatibility with stricter UPnP devices.
  • Bug Fixes

    • Fixed volume control responsiveness during remote playback.
    • System audio actions no longer interfere with active remote playback.
    • Corrected progress tracking inconsistencies between local and remote playback modes.
    • Improved end-of-track detection for UPnP devices.
  • Improvements

    • Optimized network timeout handling for better stability.
    • Enhanced state synchronization for volume and progress across playback modes.

tim and others added 2 commits April 15, 2026 12:37
- Set correct MIME type in DIDL-Lite protocolInfo (http-get:*:<mime>:*)
  instead of the wildcard http-get:*:*:* that moode/upmpdcli rejects
  when "check metadata" is enabled.  Add mimeTypeFromSuffix() helper
  and thread contentType through loadAndPlay() / _didl().
- Add Connection: close header so Dio never reuses a TCP socket that
  the renderer closed after its keepalive timeout, preventing the
  silent SocketException that froze the progress bar mid-song.
- Reduce connect/receive timeout 5 s → 3 s for faster failure detection.
- Count consecutive poll failures and log them; reset on success.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Provider / state layer:
- Make isRemotePlayback a public getter so the UI can query it.
- Gate all audio-focus and BECOMING_NOISY callbacks on !isRemotePlayback
  so Android screen-off no longer pauses the UPnP renderer.
- Replace _audioPlayer.positionStream with a unified StreamController
  fed by both the local player listener and the UPnP poll callback,
  fixing the frozen progress bar in remote-playback mode.
- Guard positionStream, playerStateStream and durationStream listeners
  so idle local-player values don't overwrite UPnP-managed state.
- Reset _upnpWasPlaying before sending Stop in playSong() and stop()
  to prevent a poll that fires mid-load from misreading STOPPED as a
  natural track end and advancing the queue twice.
- Remove the `dur > Duration.zero` guard on track-end detection;
  moode/upmpdcli returns 0:00:00 from GetPositionInfo when STOPPED,
  which caused the check to silently fail and the queue to never advance.
- Only call updateRemoteVolume() when the polled volume actually changed,
  preventing the audible snap-back on physical volume button presses.

Volume slider (now_playing_screen.dart):
- Wrap _VolumeSlider build in Consumer<PlayerProvider> so it rebuilds
  on each UPnP poll tick.
- Route drag/tap/mute/max gestures through provider.setVolume() in
  remote mode and VolumeController in local mode.

Android MediaSession (MusicService.kt):
- Use getCurrentVolume() instead of the captured constructor val in
  onAdjustVolume() so relative adjustments accumulate correctly instead
  of always computing from the initial connection volume.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 15, 2026

Warning

Rate limit exceeded

@tbrackbill has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 38 minutes and 14 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 38 minutes and 14 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7a3c2947-1b5d-4848-adb1-5deb98433c51

📥 Commits

Reviewing files that changed from the base of the PR and between 1b5d96a and f6700e2.

📒 Files selected for processing (3)
  • lib/providers/player_provider.dart
  • lib/screens/now_playing_screen.dart
  • lib/services/upnp_service.dart
📝 Walkthrough

Walkthrough

The PR enhances remote playback (UPnP/Cast) handling by introducing branching logic to distinguish between local and remote renderers. It adds isRemotePlayback detection, conditions volume and system callbacks accordingly, implements MIME type resolution for stricter UPnP renderers, and improves polling resilience with consecutive error tracking.

Changes

Cohort / File(s) Summary
Remote Volume Adjustment
android/app/src/main/kotlin/com/musly/musly/MusicService.kt
Updated VolumeProviderCompat.onAdjustVolume to compute relative volume adjustments from live current volume via getCurrentVolume() rather than a fixed baseline, maintaining clamping and forwarding to AndroidAutoPlugin.
Remote Playback Provider State
lib/providers/player_provider.dart
Added isRemotePlayback getter; modified Android system callbacks (focus, noisy) to become no-ops during remote playback; replaced positionStream with internal broadcast controller; updated UPnP flow to ignore local player state while remote is connected, reset _upnpWasPlaying flags appropriately, and emit unified position updates; simplified end-of-track detection to renderer state only; added volume state change guard in remote routing.
Volume Slider UI & Routing
lib/screens/now_playing_screen.dart
Refactored volume slider to conditionally route updates: introduced _applyVolume() to branch between local (system volume) and remote (provider volume) paths; wrapped slider UI in Consumer<PlayerProvider> to derive remote state and current volume; updated drag/tap handlers to pass routing context.
UPnP Service Resilience & Metadata
lib/services/upnp_service.dart
Added consecutivePollErrors observable state with failure tracking on null/exception results; reduced HTTP timeouts (5s→3s) and enforced socket closure per request; extended loadAndPlay() to accept and resolve optional contentType for DIDL protocol construction; introduced static mimeTypeFromSuffix() helper for audio file MIME type mapping.

Sequence Diagram

sequenceDiagram
    participant UI as UI (VolumeSlider)
    participant Provider as PlayerProvider
    participant Local as Local Player
    participant UPnP as UPnP Service
    participant Android as Android System
    
    rect rgba(100, 150, 200, 0.5)
    Note over UI,Android: Local Playback Flow
    UI->>Provider: setVolume(newVolume)
    Provider->>Local: setVolume(newVolume)
    Provider->>Android: updateSystemVolume(newVolume)
    Android->>UI: systemVolumeChanged callback
    end
    
    rect rgba(200, 150, 100, 0.5)
    Note over UI,Android: Remote Playback Flow (UPnP/Cast)
    UI->>Provider: setVolume(newVolume)
    alt isRemotePlayback == true
        Provider->>UPnP: setVolume(newVolume)
        UPnP->>UPnP: updateRemoteVolume(newVolume)
        Note over Android: Android callbacks disabled
    else
        Provider->>Local: (ignored)
        Provider->>Android: (ignored)
    end
    end
    
    rect rgba(150, 200, 100, 0.5)
    Note over Provider,UPnP: Remote Playback Position Sync
    UPnP->>UPnP: pollPlaybackState()
    UPnP->>Provider: renderer state changed
    Provider->>Provider: _positionController.add(_position)
    Provider->>UI: positionStream update
    UI->>UI: updateProgressDisplay()
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

  • PR #80: Modifies UPnP playback integration in the same files (UpnpService.loadAndPlay, PlayerProvider's UPnP state handling and polling), indicating overlapping or sequential work on remote renderer functionality.

Poem

🐰 With volume routes split here and there,
Local flows one way, remote to air,
MIME types mapped and polls that count,
Each playback path now plays the right amount!

🚥 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 title accurately summarizes the main changes across all modified files: it specifies UPnP remote playback as the focus and highlights three key aspects (protocol correctness, session reliability, and UI) that align with the actual changeset.
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

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: 4

🤖 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/providers/player_provider.dart`:
- Around line 683-687: The current isRemotePlayback getter only checks
connection flags (_upnpService.isConnected || _castService.isConnected) which
misclassifies local playback as remote; introduce an explicit boolean state
(e.g., _isRenderingRemotely) used by isRemotePlayback instead of raw connection
state, set _isRenderingRemotely = true when you actually start playback on a
remote renderer and set it = false when you drive the local _audioPlayer (see
playRadioStation and any code paths that call
_audioPlayer.play/_audioPlayer.stop), and also clear _isRenderingRemotely on
remote stop/disconnect events and on errors from _castService/_upnpService so
the audio-focus/noisy handlers and volume routing reflect the active output.
- Around line 1547-1548: The dispose/shutdown sequence currently closes
_positionController while the listeners on playerStateStream, positionStream,
and durationStream remain active; store those stream subscriptions (e.g., as
StreamSubscription fields or in a List<StreamSubscription>) when you create them
and call cancel() on each subscription before calling
_positionController.close() (and after/around _discordRpcService.shutdown()).
Update the class to keep references to the subscriptions (names like
_playerStateSub, _positionSub, _durationSub or a single _playerSubscriptions)
and ensure all are cancelled to prevent late just_audio ticks from calling
_positionController.add after disposal.

In `@lib/screens/now_playing_screen.dart`:
- Around line 2628-2637: The drag handler (_applyVolume) must keep the
optimistic UI update of _dragValue/_systemVolume but serialize remote commits to
avoid out-of-order provider.setVolume() completions; add instance fields like
_pendingRemoteVolume (double?), and _remoteCommitInProgress (bool) and when
isRemote is true, assign _pendingRemoteVolume = newVolume and kick off a single
async commit loop (if !_remoteCommitInProgress) that repeatedly reads the latest
_pendingRemoteVolume, awaits provider.setVolume(value) and then checks whether a
newer value arrived before finishing; do the same serialization change for the
other remote-write site (around the 2679-2696 block) so only the latest value is
actually awaited/sent and intermediate ticks are coalesced.

In `@lib/services/upnp_service.dart`:
- Around line 298-312: The _consecutivePollErrors counter is mutated without
notifying observers; update the code in the method that handles polling (the
block where state == null) to call notifyListeners() after incrementing
_consecutivePollErrors, and likewise call notifyListeners() after resetting
_consecutivePollErrors to 0 (the reset around lines 339-340); reference the
private field _consecutivePollErrors and the public getter consecutivePollErrors
so the changes notify any listeners watching that state.
🪄 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: 9f2b733b-f407-46e5-8352-5292df44ca2f

📥 Commits

Reviewing files that changed from the base of the PR and between c5ba9af and 1b5d96a.

📒 Files selected for processing (4)
  • android/app/src/main/kotlin/com/musly/musly/MusicService.kt
  • lib/providers/player_provider.dart
  • lib/screens/now_playing_screen.dart
  • lib/services/upnp_service.dart

Comment thread lib/providers/player_provider.dart Outdated
Comment thread lib/providers/player_provider.dart
Comment thread lib/screens/now_playing_screen.dart
Comment thread lib/services/upnp_service.dart
- Replace isRemotePlayback connection-state check with an explicit
  _isRenderingRemotely flag that is only set when audio is actually
  being sent to a remote renderer.  Avoids misclassifying local radio
  playback as remote when a UPnP device happens to be connected.

- Store playerStateStream / positionStream / durationStream subscriptions
  and cancel them in dispose() before closing _positionController, so a
  late just_audio tick can't call add() on a closed StreamController.

- Serialise async remote volume writes in _VolumeSliderState: rapid drag
  ticks now queue into _pendingRemoteVolume and a single _drainRemoteVolume
  loop awaits each provider.setVolume() call, preventing out-of-order
  responses from snapping the renderer to a stale level.

- Call notifyListeners() after incrementing and resetting
  consecutivePollErrors in UpnpService so observers see changes
  immediately rather than waiting for the next unrelated state tick.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
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