fix: UPnP remote playback — protocol correctness, session reliability, and UI#128
fix: UPnP remote playback — protocol correctness, session reliability, and UI#128tbrackbill wants to merge 3 commits intodddevid:masterfrom
Conversation
- 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]>
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThe PR enhances remote playback (UPnP/Cast) handling by introducing branching logic to distinguish between local and remote renderers. It adds Changes
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~55 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: 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
📒 Files selected for processing (4)
android/app/src/main/kotlin/com/musly/musly/MusicService.ktlib/providers/player_provider.dartlib/screens/now_playing_screen.dartlib/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]>
Two-commit fix for UPnP remote playback issues found during testing against moode/upmpdcli.
Commit 1 — UPnP service layer
protocolInfoMIME type in DIDL-Lite from the wildcardhttp-get:*:*:*tohttp-get:*:<mime>:*— strict renderers with "check metadata" enabled (moode, upmpdcli) reject the wildcard and refuse to playConnection: closerequest 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 songCommit 2 — App layer (provider + UI + Android MediaSession)
BECOMING_NOISYcallbacks on!isRemotePlaybackso Android screen-off no longer pauses the UPnP renderer_audioPlayer.positionStreamwith aStreamController.broadcast()fed by both the local player and the UPnP poll, fixing the frozen progress bar in remote mode_upnpWasPlayingbefore sending Stop to prevent a mid-load poll from misreadingSTOPPEDas a natural track enddur > Duration.zerofrom track-end detection — moode returns0:00:00fromGetPositionInfowhen stopped, which silently blocked queue advancementupdateRemoteVolume()when the polled volume actually changed — prevents the audible snap-back when pressing physical volume buttonsConsumer<PlayerProvider>and routes gestures throughprovider.setVolume()in remote modeonAdjustVolumeinMusicService.ktto usegetCurrentVolume()instead of the captured constructor val so relative adjustments accumulate correctlyWith 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
Bug Fixes
Improvements