From a60f495d99f51270e96b5e91b4e29e4237cb3f12 Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Fri, 27 Feb 2026 21:20:47 +0000 Subject: [PATCH 1/9] Fix UPnP/DLNA: auto-play on connect, position polling, rich metadata, auto-advance - Auto-play on DLNA connect: pause local audio and send current song to renderer immediately (mirrors Chromecast behavior) - 1s GetTransportInfo + GetPositionInfo polling with ChangeNotifier so seek bar, lock screen, and play/pause state update in real time - Rich DIDL-Lite metadata: album, albumArtURI (Subsonic getCoverArt), and duration attribute on - Track auto-advance: detect renderer STOPPED + position >= duration, call _onSongComplete() to play next song - Robust Play-after-SetAVTransportURI: exponential backoff retry (200ms -> 3.2s, 6 attempts) with TRANSITIONING state check, fixes race where GStreamer hasn't finished async pipeline setup for HTTPS - Fix dispose() memory leak: remove UPnP and Cast listeners - Fix connect() validation: use _soap (not _soapQuery) so HTTP errors and SOAP faults are detected --- lib/providers/player_provider.dart | 77 +++++++++ lib/services/upnp_service.dart | 247 +++++++++++++++++++++++++++-- 2 files changed, 314 insertions(+), 10 deletions(-) diff --git a/lib/providers/player_provider.dart b/lib/providers/player_provider.dart index a4808a2..db2e66b 100644 --- a/lib/providers/player_provider.dart +++ b/lib/providers/player_provider.dart @@ -67,6 +67,7 @@ class PlayerProvider extends ChangeNotifier { _storageService = storageService; _discordRpcService = DiscordRpcService(storageService); _castService.addListener(_onCastStateChanged); + _upnpService.addListener(_onUpnpStateChanged); _initializePlayer(); _initializeAndroidAuto(); _initializeSystemServices(); @@ -627,6 +628,11 @@ class PlayerProvider extends ChangeNotifier { url: playUrl, title: song.title, artist: song.artist ?? 'Unknown Artist', + album: song.album, + albumArtUrl: song.coverArt != null + ? _subsonicService.getCoverArtUrl(song.coverArt) + : null, + durationSecs: song.duration, ); } catch (e) { // SOAP call failed — disconnect so the UI reflects the real state @@ -1079,6 +1085,8 @@ class PlayerProvider extends ChangeNotifier { @override void dispose() { + _castService.removeListener(_onCastStateChanged); + _upnpService.removeListener(_onUpnpStateChanged); _audioPlayer.dispose(); _androidAutoService.dispose(); _androidSystemService.dispose(); @@ -1209,4 +1217,73 @@ class PlayerProvider extends ChangeNotifier { notifyListeners(); } } + + bool _upnpWasConnected = false; + bool _upnpWasPlaying = false; + + void _onUpnpStateChanged() { + final connected = _upnpService.isConnected; + + // On fresh connect: pause local audio, start playing current song on renderer + if (connected && !_upnpWasConnected) { + _upnpWasConnected = true; + _upnpWasPlaying = false; + if (_audioPlayer.playing) _audioPlayer.pause(); + if (_currentSong != null) { + playSong(_currentSong!); + } + return; + } + + // On disconnect: reset + if (!connected && _upnpWasConnected) { + _upnpWasConnected = false; + _upnpWasPlaying = false; + _isPlaying = false; + notifyListeners(); + return; + } + + if (!connected) return; + + // Sync position/duration from the renderer's polling data + final pos = _upnpService.rendererPosition; + final dur = _upnpService.rendererDuration; + final playing = _upnpService.isRendererPlaying; + final rendererState = _upnpService.rendererState; + + // Detect track completion: renderer was playing, now stopped, + // and position reached (or passed) the end of the track. + if (_upnpWasPlaying && + rendererState == 'STOPPED' && + dur > Duration.zero && + pos.inSeconds >= dur.inSeconds - 1) { + debugPrint('UPnP: Track ended (pos=${pos.inSeconds}s, dur=${dur.inSeconds}s) — advancing'); + _upnpWasPlaying = false; + _onSongComplete(); + return; + } + + _upnpWasPlaying = playing; + + bool changed = false; + + if ((_position - pos).abs() > const Duration(milliseconds: 500)) { + _position = pos; + changed = true; + } + if (dur != _duration && dur > Duration.zero) { + _duration = dur; + changed = true; + } + if (playing != _isPlaying) { + _isPlaying = playing; + changed = true; + } + + if (changed) { + notifyListeners(); + _updateAndroidAuto(); + } + } } diff --git a/lib/services/upnp_service.dart b/lib/services/upnp_service.dart index 09a7d58..41ca084 100644 --- a/lib/services/upnp_service.dart +++ b/lib/services/upnp_service.dart @@ -33,6 +33,18 @@ class UpnpDevice { String toString() => 'UpnpDevice($friendlyName @ $avTransportUrl)'; } +class UpnpPlaybackState { + final String transportState; // PLAYING, PAUSED_PLAYBACK, STOPPED, etc. + final Duration position; + final Duration duration; + + const UpnpPlaybackState({ + required this.transportState, + required this.position, + required this.duration, + }); +} + // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- @@ -46,6 +58,17 @@ class UpnpService extends ChangeNotifier { final List _devices = []; UpnpDevice? _connectedDevice; bool _isDiscovering = false; + Timer? _pollTimer; + + // Playback state from the renderer (updated by polling) + Duration _rendererPosition = Duration.zero; + Duration _rendererDuration = Duration.zero; + String _rendererState = 'STOPPED'; + + Duration get rendererPosition => _rendererPosition; + Duration get rendererDuration => _rendererDuration; + String get rendererState => _rendererState; + bool get isRendererPlaying => _rendererState == 'PLAYING'; List get devices => List.unmodifiable(_devices); UpnpDevice? get connectedDevice => _connectedDevice; @@ -211,9 +234,12 @@ class UpnpService extends ChangeNotifier { /// unreachable or returns a SOAP fault. Future connect(UpnpDevice device) async { try { + // Use _soap (not _soapQuery) so HTTP errors and SOAP faults throw, + // preventing connect() from succeeding against an unreachable/broken renderer. await _soap(device.avTransportUrl, 'GetTransportInfo', ''); _connectedDevice = device; debugPrint('UPnP: Connected to ${device.friendlyName}'); + _startPolling(); notifyListeners(); return true; } catch (e) { @@ -224,10 +250,90 @@ class UpnpService extends ChangeNotifier { void disconnect() { debugPrint('UPnP: Disconnected from ${_connectedDevice?.friendlyName}'); + _stopPolling(); _connectedDevice = null; + _rendererState = 'STOPPED'; + _rendererPosition = Duration.zero; + _rendererDuration = Duration.zero; notifyListeners(); } + // --- Position / state polling --- + + void _startPolling() { + _stopPolling(); + _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _poll()); + } + + void _stopPolling() { + _pollTimer?.cancel(); + _pollTimer = null; + } + + Future _poll() async { + final device = _connectedDevice; + if (device == null) return; + + try { + final state = await getPlaybackState(); + if (state == null) return; + + bool changed = false; + if (state.transportState != _rendererState) { + _rendererState = state.transportState; + changed = true; + } + if (state.position != _rendererPosition) { + _rendererPosition = state.position; + changed = true; + } + if (state.duration != _rendererDuration) { + _rendererDuration = state.duration; + changed = true; + } + if (changed) { + notifyListeners(); + } + } catch (e) { + debugPrint('UPnP: poll error: $e'); + } + } + + /// Query renderer for current transport state and position. + Future getPlaybackState() async { + final device = _connectedDevice; + if (device == null) return null; + + try { + // GetTransportInfo for state + final transportXml = await _soapQuery( + device.avTransportUrl, + 'GetTransportInfo', + '', + ); + final state = + _xmlText(transportXml, 'CurrentTransportState') ?? 'STOPPED'; + + // GetPositionInfo for position + duration + final posXml = await _soapQuery( + device.avTransportUrl, + 'GetPositionInfo', + '', + ); + final relTime = _xmlText(posXml, 'RelTime') ?? '0:00:00'; + final trackDuration = _xmlText(posXml, 'TrackDuration') ?? '0:00:00'; + + return UpnpPlaybackState( + transportState: state, + position: _parseTime(relTime), + duration: _parseTime(trackDuration), + ); + } catch (e) { + debugPrint('UPnP: getPlaybackState error: $e'); + return null; + } + } + // --- Playback control --- /// Load [url] on the connected renderer and start playback. @@ -237,7 +343,9 @@ class UpnpService extends ChangeNotifier { required String url, required String title, required String artist, - String? albumUri, + String? album, + String? albumArtUrl, + int? durationSecs, }) async { final device = _connectedDevice; if (device == null) { @@ -261,7 +369,14 @@ class UpnpService extends ChangeNotifier { await Future.delayed(const Duration(milliseconds: 300)); // SetAVTransportURI — throws on fault or network error - final didl = _didl(title: title, artist: artist, url: url); + final didl = _didl( + title: title, + artist: artist, + url: url, + album: album, + albumArtUrl: albumArtUrl, + durationSecs: durationSecs, + ); debugPrint('UPnP: SetAVTransportURI…'); await _soap( device.avTransportUrl, @@ -271,13 +386,55 @@ class UpnpService extends ChangeNotifier { ); debugPrint('UPnP: SetAVTransportURI OK'); - // Wait for the renderer to load the URI before sending Play - await Future.delayed(const Duration(milliseconds: 800)); + // Send Play with retry and back-off. + // + // Some renderers (notably gmrender-resurrect with GStreamer) need time + // after SetAVTransportURI to set up their pipeline for remote HTTPS + // streams. If Play arrives too early the renderer returns UPnP error + // 501/704 ("Playing failed"). Spec-compliant renderers may report + // TRANSITIONING in GetTransportInfo while they buffer. + // + // Strategy (inspired by Home Assistant async_upnp_client): + // 1. Poll GetTransportInfo — skip Play while TRANSITIONING. + // 2. On Play failure with a retriable UPnP error, back off and retry. + // 3. Exponential delays: 200 → 400 → 800 → 1600 → 3200 ms. + // Total worst-case wait ~6.2 s (enough for slow HTTPS + TLS). + debugPrint('UPnP: Waiting for renderer ready…'); + const maxAttempts = 6; + var delay = const Duration(milliseconds: 200); + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + await Future.delayed(delay); + + // Check transport state — wait if renderer reports TRANSITIONING. + try { + final xml = await _soapQuery( + device.avTransportUrl, + 'GetTransportInfo', + '', + ); + final state = _xmlText(xml, 'CurrentTransportState') ?? ''; + if (state == 'TRANSITIONING') { + debugPrint('UPnP: Renderer TRANSITIONING, waiting… (attempt $attempt)'); + delay *= 2; + continue; + } + } catch (_) { + // If we can't even query state, just try Play anyway. + } - debugPrint('UPnP: Play…'); - await _soap(device.avTransportUrl, 'Play', '1'); - debugPrint('UPnP: Playing "$title" on ${device.friendlyName}'); - return true; + // Attempt Play. + try { + await _soap(device.avTransportUrl, 'Play', '1'); + debugPrint('UPnP: Playing "$title" on ${device.friendlyName}'); + return true; + } catch (e) { + debugPrint('UPnP: Play attempt $attempt/$maxAttempts failed: $e'); + if (attempt == maxAttempts) rethrow; + delay *= 2; + } + } + return false; } Future pause() async { @@ -327,6 +484,7 @@ class UpnpService extends ChangeNotifier { // --- SOAP helpers --- + /// Fire-and-forget SOAP action (throws on error). Future _soap(String controlUrl, String action, String body) async { const serviceType = 'urn:schemas-upnp-org:service:AVTransport:1'; final envelope = @@ -401,7 +559,42 @@ class UpnpService extends ChangeNotifier { } } - /// Minimal DIDL-Lite metadata for the renderer's Now Playing display. + /// SOAP query that returns the response body XML for parsing. + Future _soapQuery( + String controlUrl, + String action, + String body, + ) async { + const serviceType = 'urn:schemas-upnp-org:service:AVTransport:1'; + final envelope = + '\n' + '\n' + ' \n' + ' \n' + ' 0\n' + ' $body\n' + ' \n' + ' \n' + ''; + + final response = await _dio.post( + controlUrl, + data: envelope, + options: Options( + headers: { + 'Content-Type': 'text/xml; charset="utf-8"', + 'SOAPAction': '"$serviceType#$action"', + }, + validateStatus: (_) => true, + responseType: ResponseType.plain, + ), + ); + + return response.data ?? ''; + } + + /// DIDL-Lite metadata for the renderer's Now Playing display. /// /// Returns an already-XML-escaped DIDL string, safe to embed as the text /// content of `` in the SOAP body. @@ -409,6 +602,9 @@ class UpnpService extends ChangeNotifier { required String title, required String artist, required String url, + String? album, + String? albumArtUrl, + int? durationSecs, }) { String esc(String s) => s .replaceAll('&', '&') @@ -420,6 +616,17 @@ class UpnpService extends ChangeNotifier { // including Samsung TVs which are picky about MIME type matching. const protocol = 'http-get:*:*:*'; + // Format duration as HH:MM:SS for the element + final durationAttr = durationSecs != null + ? ' duration="${_formatTimeSecs(durationSecs)}"' + : ''; + + final albumTag = + album != null ? '${esc(album)}' : ''; + final artTag = albumArtUrl != null + ? '${esc(albumArtUrl)}' + : ''; + final didl = '${esc(title)}' '${esc(artist)}' '${esc(artist)}' + '$albumTag' + '$artTag' 'object.item.audioItem.musicTrack' - '${esc(url)}' + '${esc(url)}' ''; // XML-escape the whole DIDL so it sits as text content of @@ -452,8 +661,26 @@ class UpnpService extends ChangeNotifier { return '$h:$m:$s'; } + static String _formatTimeSecs(int totalSeconds) { + final h = (totalSeconds ~/ 3600).toString().padLeft(2, '0'); + final m = ((totalSeconds % 3600) ~/ 60).toString().padLeft(2, '0'); + final s = (totalSeconds % 60).toString().padLeft(2, '0'); + return '$h:$m:$s'; + } + + static Duration _parseTime(String hms) { + if (hms == 'NOT_IMPLEMENTED' || hms.isEmpty) return Duration.zero; + final parts = hms.split(':'); + if (parts.length != 3) return Duration.zero; + final h = int.tryParse(parts[0]) ?? 0; + final m = int.tryParse(parts[1]) ?? 0; + final s = int.tryParse(parts[2].split('.')[0]) ?? 0; + return Duration(hours: h, minutes: m, seconds: s); + } + @override void dispose() { + _stopPolling(); _dio.close(); super.dispose(); } From 4f60eed0cdf88ac964a154a40bb7bbb69518f007 Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Fri, 27 Feb 2026 22:19:11 +0000 Subject: [PATCH 2/9] Send Stop on UPnP disconnect, omit size param for full-res cover art - disconnect() now fires a Stop SOAP command so the renderer actually stops playback when the user disconnects from the cast device - getCoverArtUrl with size=0 now omits the size parameter entirely, letting Navidrome return the original full-resolution cover art (needed for DIDL metadata sent to UPnP renderers) --- lib/services/subsonic_service.dart | 4 +++- lib/services/upnp_service.dart | 12 +++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/services/subsonic_service.dart b/lib/services/subsonic_service.dart index bf26b9a..8d30e2e 100644 --- a/lib/services/subsonic_service.dart +++ b/lib/services/subsonic_service.dart @@ -273,7 +273,9 @@ class SubsonicService { _stableAuthParams ?? _getAuthParams(), ); params['id'] = coverArt; - params['size'] = size.toString(); + if (size > 0) { + params['size'] = size.toString(); + } if (_config!.selectedMusicFolderIds.isNotEmpty) { params['musicFolderId'] = _config!.selectedMusicFolderIds.first; diff --git a/lib/services/upnp_service.dart b/lib/services/upnp_service.dart index 41ca084..2efadad 100644 --- a/lib/services/upnp_service.dart +++ b/lib/services/upnp_service.dart @@ -249,13 +249,23 @@ class UpnpService extends ChangeNotifier { } void disconnect() { - debugPrint('UPnP: Disconnected from ${_connectedDevice?.friendlyName}'); + final device = _connectedDevice; + debugPrint('UPnP: Disconnecting from ${device?.friendlyName}'); _stopPolling(); _connectedDevice = null; _rendererState = 'STOPPED'; _rendererPosition = Duration.zero; _rendererDuration = Duration.zero; notifyListeners(); + + // Fire-and-forget Stop so the renderer actually stops playback + if (device != null) { + _soap(device.avTransportUrl, 'Stop', '').then((_) { + debugPrint('UPnP: Stop sent on disconnect'); + }).catchError((e) { + debugPrint('UPnP: Stop on disconnect failed (ok): $e'); + }); + } } // --- Position / state polling --- From 887094d13a0719bfdd19ca2167453c8316de8c34 Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Fri, 27 Feb 2026 22:22:16 +0000 Subject: [PATCH 3/9] Address CodeRabbit review: poll guard, fault rejection, backoff cap, state reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Serialize _poll() with _isPolling flag to prevent overlapping network cycles - _soapQuery() now rejects HTTP errors and SOAP faults instead of returning fault XML silently — prevents polling from misinterpreting fault as STOPPED - Exponential backoff in Play retry clamped to 3200ms max - Reset _position/_duration to zero on UPnP disconnect to clear stale UI - Accept zero duration from renderer instead of ignoring it --- lib/providers/player_provider.dart | 8 +++++--- lib/services/upnp_service.dart | 25 ++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/lib/providers/player_provider.dart b/lib/providers/player_provider.dart index db2e66b..ee6b723 100644 --- a/lib/providers/player_provider.dart +++ b/lib/providers/player_provider.dart @@ -630,7 +630,7 @@ class PlayerProvider extends ChangeNotifier { artist: song.artist ?? 'Unknown Artist', album: song.album, albumArtUrl: song.coverArt != null - ? _subsonicService.getCoverArtUrl(song.coverArt) + ? _subsonicService.getCoverArtUrl(song.coverArt, size: 0) : null, durationSecs: song.duration, ); @@ -1235,11 +1235,13 @@ class PlayerProvider extends ChangeNotifier { return; } - // On disconnect: reset + // On disconnect: reset all state so UI doesn't show stale values if (!connected && _upnpWasConnected) { _upnpWasConnected = false; _upnpWasPlaying = false; _isPlaying = false; + _position = Duration.zero; + _duration = Duration.zero; notifyListeners(); return; } @@ -1272,7 +1274,7 @@ class PlayerProvider extends ChangeNotifier { _position = pos; changed = true; } - if (dur != _duration && dur > Duration.zero) { + if (dur != _duration) { _duration = dur; changed = true; } diff --git a/lib/services/upnp_service.dart b/lib/services/upnp_service.dart index 2efadad..da45c28 100644 --- a/lib/services/upnp_service.dart +++ b/lib/services/upnp_service.dart @@ -280,9 +280,13 @@ class UpnpService extends ChangeNotifier { _pollTimer = null; } + bool _isPolling = false; + Future _poll() async { + if (_isPolling) return; // prevent overlapping polls final device = _connectedDevice; if (device == null) return; + _isPolling = true; try { final state = await getPlaybackState(); @@ -306,6 +310,8 @@ class UpnpService extends ChangeNotifier { } } catch (e) { debugPrint('UPnP: poll error: $e'); + } finally { + _isPolling = false; } } @@ -426,7 +432,9 @@ class UpnpService extends ChangeNotifier { final state = _xmlText(xml, 'CurrentTransportState') ?? ''; if (state == 'TRANSITIONING') { debugPrint('UPnP: Renderer TRANSITIONING, waiting… (attempt $attempt)'); - delay *= 2; + delay = delay * 2 < const Duration(milliseconds: 3200) + ? delay * 2 + : const Duration(milliseconds: 3200); continue; } } catch (_) { @@ -441,7 +449,9 @@ class UpnpService extends ChangeNotifier { } catch (e) { debugPrint('UPnP: Play attempt $attempt/$maxAttempts failed: $e'); if (attempt == maxAttempts) rethrow; - delay *= 2; + delay = delay * 2 < const Duration(milliseconds: 3200) + ? delay * 2 + : const Duration(milliseconds: 3200); } } return false; @@ -601,7 +611,16 @@ class UpnpService extends ChangeNotifier { ), ); - return response.data ?? ''; + final status = response.statusCode ?? 0; + final responseBody = response.data ?? ''; + if (status < 200 || status >= 300) { + throw Exception('UPnP SOAP $action failed — HTTP $status'); + } + if (responseBody.contains('')) { + throw Exception('UPnP SOAP fault for $action'); + } + return responseBody; } /// DIDL-Lite metadata for the renderer's Now Playing display. From 8fe86308c22df6839d6051fa62ebe390fa043342 Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Fri, 27 Feb 2026 22:32:34 +0000 Subject: [PATCH 4/9] Speed up song transitions: try Play immediately, reduce backoff - Remove 300ms post-Stop delay (unnecessary for gmrender-resurrect) - Try Play immediately after SetAVTransportURI with no initial wait - Only fall back to retry/backoff if the instant Play fails - Reduce initial backoff from 200ms to 150ms, cap at 2400ms - Cuts ~1-3 seconds off track-to-track transition time --- lib/services/upnp_service.dart | 48 +++++++++++++++------------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/lib/services/upnp_service.dart b/lib/services/upnp_service.dart index da45c28..905dc5a 100644 --- a/lib/services/upnp_service.dart +++ b/lib/services/upnp_service.dart @@ -381,9 +381,6 @@ class UpnpService extends ChangeNotifier { debugPrint('UPnP: Stop failed (ignoring): $e'); } - // Brief pause so the renderer resets its state - await Future.delayed(const Duration(milliseconds: 300)); - // SetAVTransportURI — throws on fault or network error final didl = _didl( title: title, @@ -402,22 +399,20 @@ class UpnpService extends ChangeNotifier { ); debugPrint('UPnP: SetAVTransportURI OK'); - // Send Play with retry and back-off. - // - // Some renderers (notably gmrender-resurrect with GStreamer) need time - // after SetAVTransportURI to set up their pipeline for remote HTTPS - // streams. If Play arrives too early the renderer returns UPnP error - // 501/704 ("Playing failed"). Spec-compliant renderers may report - // TRANSITIONING in GetTransportInfo while they buffer. - // - // Strategy (inspired by Home Assistant async_upnp_client): - // 1. Poll GetTransportInfo — skip Play while TRANSITIONING. - // 2. On Play failure with a retriable UPnP error, back off and retry. - // 3. Exponential delays: 200 → 400 → 800 → 1600 → 3200 ms. - // Total worst-case wait ~6.2 s (enough for slow HTTPS + TLS). - debugPrint('UPnP: Waiting for renderer ready…'); - const maxAttempts = 6; - var delay = const Duration(milliseconds: 200); + // Try Play immediately — many renderers accept it right away. + // Only fall back to retry/backoff if the first attempt fails. + try { + await _soap(device.avTransportUrl, 'Play', '1'); + debugPrint('UPnP: Playing "$title" on ${device.friendlyName} (instant)'); + return true; + } catch (e) { + debugPrint('UPnP: Instant Play failed ($e), retrying with backoff…'); + } + + // Retry with backoff — renderer may need time for HTTPS pipeline setup. + // Delays: 150 → 300 → 600 → 1200 → 2400 ms (total worst-case ~4.7s). + const maxAttempts = 5; + var delay = const Duration(milliseconds: 150); for (int attempt = 1; attempt <= maxAttempts; attempt++) { await Future.delayed(delay); @@ -431,27 +426,26 @@ class UpnpService extends ChangeNotifier { ); final state = _xmlText(xml, 'CurrentTransportState') ?? ''; if (state == 'TRANSITIONING') { - debugPrint('UPnP: Renderer TRANSITIONING, waiting… (attempt $attempt)'); - delay = delay * 2 < const Duration(milliseconds: 3200) + debugPrint('UPnP: Renderer TRANSITIONING (attempt $attempt)'); + delay = delay * 2 < const Duration(milliseconds: 2400) ? delay * 2 - : const Duration(milliseconds: 3200); + : const Duration(milliseconds: 2400); continue; } } catch (_) { - // If we can't even query state, just try Play anyway. + // Can't query state — try Play anyway. } - // Attempt Play. try { await _soap(device.avTransportUrl, 'Play', '1'); - debugPrint('UPnP: Playing "$title" on ${device.friendlyName}'); + debugPrint('UPnP: Playing "$title" on ${device.friendlyName} (attempt $attempt)'); return true; } catch (e) { debugPrint('UPnP: Play attempt $attempt/$maxAttempts failed: $e'); if (attempt == maxAttempts) rethrow; - delay = delay * 2 < const Duration(milliseconds: 3200) + delay = delay * 2 < const Duration(milliseconds: 2400) ? delay * 2 - : const Duration(milliseconds: 3200); + : const Duration(milliseconds: 2400); } } return false; From 409805ba137164aaa4b4e150ebb9270af0e01fb0 Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Fri, 27 Feb 2026 23:19:41 +0000 Subject: [PATCH 5/9] Fix stale song on cast/UPnP connect, case-insensitive SOAP fault detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On fresh connect, playSong() was short-circuiting into togglePlayPause() because _currentSong already matched — just resuming whatever old track the renderer had loaded. Now clear _currentSong first so playSong() does a full loadAndPlay. Also align _soapQuery fault detection with _soap: use toLowerCase() and check , , consistently. --- lib/providers/player_provider.dart | 12 ++++++++++-- lib/services/upnp_service.dart | 6 ++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/providers/player_provider.dart b/lib/providers/player_provider.dart index ee6b723..f4d28ee 100644 --- a/lib/providers/player_provider.dart +++ b/lib/providers/player_provider.dart @@ -1209,7 +1209,10 @@ class PlayerProvider extends ChangeNotifier { if (_castService.isConnected) { _audioPlayer.pause(); // Ensure local is paused if (_currentSong != null) { - playSong(_currentSong!); + // Clear so playSong() does a full load instead of togglePlayPause() + final song = _currentSong!; + _currentSong = null; + playSong(song); } } else { // Cast disconnected @@ -1230,7 +1233,12 @@ class PlayerProvider extends ChangeNotifier { _upnpWasPlaying = false; if (_audioPlayer.playing) _audioPlayer.pause(); if (_currentSong != null) { - playSong(_currentSong!); + // Clear _currentSong so playSong() does a full loadAndPlay on the + // renderer instead of short-circuiting into togglePlayPause() (which + // would just resume whatever old track the renderer had loaded). + final song = _currentSong!; + _currentSong = null; + playSong(song); } return; } diff --git a/lib/services/upnp_service.dart b/lib/services/upnp_service.dart index 905dc5a..9a52c3c 100644 --- a/lib/services/upnp_service.dart +++ b/lib/services/upnp_service.dart @@ -610,8 +610,10 @@ class UpnpService extends ChangeNotifier { if (status < 200 || status >= 300) { throw Exception('UPnP SOAP $action failed — HTTP $status'); } - if (responseBody.contains('')) { + final lowerBody = responseBody.toLowerCase(); + if (lowerBody.contains('') || + lowerBody.contains('') || + lowerBody.contains('')) { throw Exception('UPnP SOAP fault for $action'); } return responseBody; From 38749055cc99b8ca815cac1fe4984fd2bf37b806 Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Sat, 28 Feb 2026 00:19:12 +0000 Subject: [PATCH 6/9] Fix Android media notification not showing, address CodeRabbit review - Add POST_NOTIFICATIONS permission (required since API 33) - Change MusicService stopWithTask to false so notification persists when app is backgrounded - Start MusicService lazily on first playback instead of eagerly on plugin attach (fixes silent failure on Android 16) - Auto-restart MusicService if killed: both AndroidAutoPlugin and AndroidSystemPlugin detect null instance and restart before updating playback state - Set instance=null in onDestroy so null-check restart triggers - Change START_STICKY to START_NOT_STICKY (service should only live while media is playing) - Remove duplicate updatePlaybackState call in _updateAndroidAuto() - Update notification on Cast/UPnP play, pause, and disconnect - Fix unreachable code: return false instead of rethrow on final Play retry attempt (CodeRabbit review) --- android/app/src/main/AndroidManifest.xml | 3 +- .../com/musly/musly/AndroidAutoPlugin.kt | 30 +++++++++++++------ .../com/musly/musly/AndroidSystemPlugin.kt | 7 ++++- .../kotlin/com/musly/musly/MusicService.kt | 15 +++++++--- lib/providers/player_provider.dart | 17 ++++------- lib/services/upnp_service.dart | 2 +- 6 files changed, 47 insertions(+), 27 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d7b0dd8..b5c96c4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ + @@ -85,7 +86,7 @@ android:name=".MusicService" android:exported="true" android:enabled="true" - android:stopWithTask="true" + android:stopWithTask="false" android:foregroundServiceType="mediaPlayback"> diff --git a/android/app/src/main/kotlin/com/musly/musly/AndroidAutoPlugin.kt b/android/app/src/main/kotlin/com/musly/musly/AndroidAutoPlugin.kt index 762be93..18834e0 100644 --- a/android/app/src/main/kotlin/com/musly/musly/AndroidAutoPlugin.kt +++ b/android/app/src/main/kotlin/com/musly/musly/AndroidAutoPlugin.kt @@ -2,6 +2,7 @@ package com.devid.musly import android.content.Context import android.content.Intent +import android.util.Log import androidx.core.content.ContextCompat import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.EventChannel @@ -9,7 +10,8 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel object AndroidAutoPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { - + + private const val TAG = "AndroidAutoPlugin" private const val METHOD_CHANNEL = "com.devid.musly/android_auto" private const val EVENT_CHANNEL = "com.devid.musly/android_auto_events" @@ -29,13 +31,13 @@ object AndroidAutoPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { eventSink = events } - + override fun onCancel(arguments: Any?) { eventSink = null } }) - - startMusicService() + + Log.d(TAG, "AndroidAutoPlugin attached (service will start on first playback)") } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -65,7 +67,12 @@ object AndroidAutoPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { val duration = call.argument("duration")?.toLong() ?: 0L val position = call.argument("position")?.toLong() ?: 0L val playing = call.argument("playing") ?: false - + + // Ensure the service is running before updating state + if (MusicService.getInstance() == null) { + Log.d(TAG, "MusicService not running, starting it now") + startMusicService() + } MusicService.getInstance()?.updatePlaybackState( songId, title, artist, album, artworkUrl, duration, position, playing ) @@ -113,11 +120,16 @@ object AndroidAutoPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { } } - private fun startMusicService() { + fun startMusicService() { context?.let { ctx -> - val intent = Intent(ctx, MusicService::class.java) - ContextCompat.startForegroundService(ctx, intent) - } + try { + val intent = Intent(ctx, MusicService::class.java) + ContextCompat.startForegroundService(ctx, intent) + Log.d(TAG, "startForegroundService called successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to start MusicService: ${e.message}", e) + } + } ?: Log.w(TAG, "Cannot start MusicService: context is null") } private fun stopMusicService() { diff --git a/android/app/src/main/kotlin/com/musly/musly/AndroidSystemPlugin.kt b/android/app/src/main/kotlin/com/musly/musly/AndroidSystemPlugin.kt index 4065889..40f991d 100644 --- a/android/app/src/main/kotlin/com/musly/musly/AndroidSystemPlugin.kt +++ b/android/app/src/main/kotlin/com/musly/musly/AndroidSystemPlugin.kt @@ -122,7 +122,12 @@ object AndroidSystemPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { val duration = call.argument("duration")?.toLong() ?: 0L val position = call.argument("position")?.toLong() ?: 0L val playing = call.argument("playing") ?: false - + + // Ensure the service is running before updating state + if (MusicService.getInstance() == null) { + Log.d(TAG, "MusicService not running, requesting start via AndroidAutoPlugin") + AndroidAutoPlugin.startMusicService() + } MusicService.getInstance()?.updatePlaybackState( songId, title, artist, album, artworkUrl, duration, position, playing ) diff --git a/android/app/src/main/kotlin/com/musly/musly/MusicService.kt b/android/app/src/main/kotlin/com/musly/musly/MusicService.kt index 4070542..ec3c0d8 100644 --- a/android/app/src/main/kotlin/com/musly/musly/MusicService.kt +++ b/android/app/src/main/kotlin/com/musly/musly/MusicService.kt @@ -18,12 +18,14 @@ import android.support.v4.media.session.PlaybackStateCompat import androidx.core.app.NotificationCompat import androidx.media.MediaBrowserServiceCompat import androidx.media.session.MediaButtonReceiver +import android.util.Log import kotlinx.coroutines.* import java.net.URL class MusicService : MediaBrowserServiceCompat() { companion object { + private const val TAG = "MusicService" private const val CHANNEL_ID = "musly_music_channel" private const val NOTIFICATION_ID = 1 private const val MY_MEDIA_ROOT_ID = "media_root_id" @@ -72,16 +74,18 @@ class MusicService : MediaBrowserServiceCompat() { override fun onCreate() { super.onCreate() instance = this - + Log.d(TAG, "MusicService onCreate") + createNotificationChannel() initializeMediaSession() - + showIdleNotification() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "MusicService onStartCommand action=${intent?.action}") MediaButtonReceiver.handleIntent(mediaSession, intent) - return START_STICKY + return START_NOT_STICKY } private fun showIdleNotification() { @@ -634,13 +638,16 @@ class MusicService : MediaBrowserServiceCompat() { } override fun onDestroy() { + Log.d(TAG, "MusicService onDestroy") + instance = null super.onDestroy() serviceScope.cancel() mediaSession.isActive = false mediaSession.release() } - + override fun onTaskRemoved(rootIntent: Intent?) { + Log.d(TAG, "MusicService onTaskRemoved") super.onTaskRemoved(rootIntent) mediaSession.isActive = false stopForeground(true) diff --git a/lib/providers/player_provider.dart b/lib/providers/player_provider.dart index f4d28ee..efc80d5 100644 --- a/lib/providers/player_provider.dart +++ b/lib/providers/player_provider.dart @@ -328,17 +328,6 @@ class PlayerProvider extends ChangeNotifier { isPlaying: _isPlaying, ); - _androidAutoService.updatePlaybackState( - songId: _currentSong!.id, - title: _currentSong!.title, - artist: _currentSong!.artist ?? '', - album: _currentSong!.album ?? '', - artworkUrl: artworkUrl, - duration: _duration, - position: _position, - isPlaying: _isPlaying, - ); - _updateDiscordRpc(); _updateAllServices(); } @@ -790,10 +779,12 @@ class PlayerProvider extends ChangeNotifier { await _castService.play(); _isPlaying = true; notifyListeners(); + _updateAndroidAuto(); } else if (_upnpService.isConnected) { await _upnpService.play(); _isPlaying = true; notifyListeners(); + _updateAndroidAuto(); } else { await _audioPlayer.play(); } @@ -804,10 +795,12 @@ class PlayerProvider extends ChangeNotifier { await _castService.pause(); _isPlaying = false; notifyListeners(); + _updateAndroidAuto(); } else if (_upnpService.isConnected) { await _upnpService.pause(); _isPlaying = false; notifyListeners(); + _updateAndroidAuto(); } else { await _audioPlayer.pause(); } @@ -1218,6 +1211,7 @@ class PlayerProvider extends ChangeNotifier { // Cast disconnected _isPlaying = false; notifyListeners(); + _updateAndroidAuto(); } } @@ -1251,6 +1245,7 @@ class PlayerProvider extends ChangeNotifier { _position = Duration.zero; _duration = Duration.zero; notifyListeners(); + _updateAndroidAuto(); return; } diff --git a/lib/services/upnp_service.dart b/lib/services/upnp_service.dart index 9a52c3c..9509e2c 100644 --- a/lib/services/upnp_service.dart +++ b/lib/services/upnp_service.dart @@ -442,7 +442,7 @@ class UpnpService extends ChangeNotifier { return true; } catch (e) { debugPrint('UPnP: Play attempt $attempt/$maxAttempts failed: $e'); - if (attempt == maxAttempts) rethrow; + if (attempt == maxAttempts) return false; delay = delay * 2 < const Duration(milliseconds: 2400) ? delay * 2 : const Duration(milliseconds: 2400); From 81f25ed026580633ac006a797af36e9a99064e56 Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Sat, 28 Feb 2026 00:56:13 +0000 Subject: [PATCH 7/9] Add volume control for UPnP/DLNA and Cast, fix async service start Volume control: - UPnP: RenderingControl SOAP (SetVolume/GetVolume), polled every 5s - Cast: wire setVolume() to native sessionManager.setDeviceVolume() - PlayerProvider routes volume to active Cast/UPnP device - Volume slider in UPnP control dialog with live feedback - Android VolumeProviderCompat on MediaSession for system cast volume slider - Hardware volume buttons control renderer when Cast/UPnP connected CodeRabbit fixes: - Defer updatePlaybackState after async startForegroundService (postDelayed) - Handle loadAndPlay() returning false instead of ignoring result --- .../com/musly/musly/AndroidAutoPlugin.kt | 17 ++- .../com/musly/musly/AndroidSystemPlugin.kt | 23 +++- .../kotlin/com/musly/musly/MusicService.kt | 34 ++++- lib/providers/player_provider.dart | 39 +++++- lib/services/android_auto_service.dart | 7 ++ lib/services/android_system_service.dart | 26 ++++ lib/services/cast_service.dart | 10 +- lib/services/upnp_service.dart | 119 ++++++++++++++++++ lib/widgets/cast_button.dart | 55 ++++++-- 9 files changed, 304 insertions(+), 26 deletions(-) diff --git a/android/app/src/main/kotlin/com/musly/musly/AndroidAutoPlugin.kt b/android/app/src/main/kotlin/com/musly/musly/AndroidAutoPlugin.kt index 18834e0..483ede9 100644 --- a/android/app/src/main/kotlin/com/musly/musly/AndroidAutoPlugin.kt +++ b/android/app/src/main/kotlin/com/musly/musly/AndroidAutoPlugin.kt @@ -2,6 +2,8 @@ package com.devid.musly import android.content.Context import android.content.Intent +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.core.content.ContextCompat import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -14,11 +16,12 @@ object AndroidAutoPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { private const val TAG = "AndroidAutoPlugin" private const val METHOD_CHANNEL = "com.devid.musly/android_auto" private const val EVENT_CHANNEL = "com.devid.musly/android_auto_events" - + private var methodChannel: MethodChannel? = null private var eventChannel: EventChannel? = null private var eventSink: EventChannel.EventSink? = null private var context: Context? = null + private val mainHandler = Handler(Looper.getMainLooper()) override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { context = binding.applicationContext @@ -69,13 +72,19 @@ object AndroidAutoPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { val playing = call.argument("playing") ?: false // Ensure the service is running before updating state + val pushState = { + MusicService.getInstance()?.updatePlaybackState( + songId, title, artist, album, artworkUrl, duration, position, playing + ) + } if (MusicService.getInstance() == null) { Log.d(TAG, "MusicService not running, starting it now") startMusicService() + // Service start is async; retry after a short delay + mainHandler.postDelayed({ pushState() }, 200) + } else { + pushState() } - MusicService.getInstance()?.updatePlaybackState( - songId, title, artist, album, artworkUrl, duration, position, playing - ) result.success(null) } "updateRecentSongs" -> { diff --git a/android/app/src/main/kotlin/com/musly/musly/AndroidSystemPlugin.kt b/android/app/src/main/kotlin/com/musly/musly/AndroidSystemPlugin.kt index 40f991d..e82a986 100644 --- a/android/app/src/main/kotlin/com/musly/musly/AndroidSystemPlugin.kt +++ b/android/app/src/main/kotlin/com/musly/musly/AndroidSystemPlugin.kt @@ -11,6 +11,7 @@ import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log + import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall @@ -124,13 +125,18 @@ object AndroidSystemPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { val playing = call.argument("playing") ?: false // Ensure the service is running before updating state + val pushState = { + MusicService.getInstance()?.updatePlaybackState( + songId, title, artist, album, artworkUrl, duration, position, playing + ) + } if (MusicService.getInstance() == null) { Log.d(TAG, "MusicService not running, requesting start via AndroidAutoPlugin") AndroidAutoPlugin.startMusicService() + handler.postDelayed({ pushState() }, 200) + } else { + pushState() } - MusicService.getInstance()?.updatePlaybackState( - songId, title, artist, album, artworkUrl, duration, position, playing - ) result.success(null) } "setNotificationColor" -> { @@ -161,6 +167,17 @@ object AndroidSystemPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { "getAndroidSdkVersion" -> { result.success(Build.VERSION.SDK_INT) } + "setRemotePlayback" -> { + val isRemote = call.argument("isRemote") ?: false + val volume = call.argument("volume") ?: 50 + MusicService.getInstance()?.setRemoteVolume(isRemote, volume) + result.success(null) + } + "updateRemoteVolume" -> { + val volume = call.argument("volume") ?: 50 + MusicService.getInstance()?.updateRemoteVolume(volume) + result.success(null) + } "dispose" -> { dispose() result.success(null) diff --git a/android/app/src/main/kotlin/com/musly/musly/MusicService.kt b/android/app/src/main/kotlin/com/musly/musly/MusicService.kt index ec3c0d8..02fbc90 100644 --- a/android/app/src/main/kotlin/com/musly/musly/MusicService.kt +++ b/android/app/src/main/kotlin/com/musly/musly/MusicService.kt @@ -18,7 +18,9 @@ import android.support.v4.media.session.PlaybackStateCompat import androidx.core.app.NotificationCompat import androidx.media.MediaBrowserServiceCompat import androidx.media.session.MediaButtonReceiver +import android.media.AudioManager import android.util.Log +import androidx.media.VolumeProviderCompat import kotlinx.coroutines.* import java.net.URL @@ -56,7 +58,8 @@ class MusicService : MediaBrowserServiceCompat() { private var currentDuration: Long = 0 private var currentPosition: Long = 0 private var isPlaying: Boolean = false - + private var volumeProvider: VolumeProviderCompat? = null + private val mediaItems = mutableListOf() private val recentSongs = mutableListOf() private val albums = mutableListOf() @@ -637,6 +640,35 @@ class MusicService : MediaBrowserServiceCompat() { } } + fun setRemoteVolume(isRemote: Boolean, currentVolume: Int) { + if (isRemote) { + volumeProvider = object : VolumeProviderCompat( + VOLUME_CONTROL_ABSOLUTE, 100, currentVolume + ) { + override fun onSetVolumeTo(volume: Int) { + setCurrentVolume(volume) + AndroidAutoPlugin.sendCommand("setVolume", mapOf("volume" to volume)) + } + + override fun onAdjustVolume(direction: Int) { + val newVolume = (currentVolume + direction * 5).coerceIn(0, 100) + setCurrentVolume(newVolume) + AndroidAutoPlugin.sendCommand("setVolume", mapOf("volume" to newVolume)) + } + } + mediaSession.setPlaybackToRemote(volumeProvider!!) + Log.d(TAG, "MediaSession set to remote volume (current=$currentVolume)") + } else { + volumeProvider = null + mediaSession.setPlaybackToLocal(AudioManager.STREAM_MUSIC) + Log.d(TAG, "MediaSession set to local volume") + } + } + + fun updateRemoteVolume(volume: Int) { + volumeProvider?.currentVolume = volume + } + override fun onDestroy() { Log.d(TAG, "MusicService onDestroy") instance = null diff --git a/lib/providers/player_provider.dart b/lib/providers/player_provider.dart index efc80d5..716395f 100644 --- a/lib/providers/player_provider.dart +++ b/lib/providers/player_provider.dart @@ -180,6 +180,7 @@ class PlayerProvider extends ChangeNotifier { _androidAutoService.onSkipPrevious = skipPrevious; _androidAutoService.onSeekTo = seek; _androidAutoService.onPlayFromMediaId = _playFromMediaId; + _androidAutoService.onSetVolume = _onRemoteVolumeChange; _androidAutoService.onGetAlbumSongs = _getAlbumSongsForAndroidAuto; _androidAutoService.onGetArtistAlbums = _getArtistAlbumsForAndroidAuto; @@ -613,7 +614,7 @@ class PlayerProvider extends ChangeNotifier { : await _subsonicService.getStreamUrl(song.id); try { - await _upnpService.loadAndPlay( + final success = await _upnpService.loadAndPlay( url: playUrl, title: song.title, artist: song.artist ?? 'Unknown Artist', @@ -623,6 +624,11 @@ class PlayerProvider extends ChangeNotifier { : null, durationSecs: song.duration, ); + if (!success) { + _upnpService.disconnect(); + debugPrint('UPnP playback failed (retries exhausted), disconnected'); + return; + } } catch (e) { // SOAP call failed — disconnect so the UI reflects the real state _upnpService.disconnect(); @@ -1006,10 +1012,25 @@ class PlayerProvider extends ChangeNotifier { Future setVolume(double volume) async { _volume = volume.clamp(0.0, 1.0); await _storageService.saveVolume(_volume); - await _applyReplayGain(_currentSong); + if (_castService.isConnected) { + await _castService.setVolume(_volume); + } else if (_upnpService.isConnected) { + await _upnpService.setVolume((_volume * 100).round()); + } else { + await _applyReplayGain(_currentSong); + } notifyListeners(); } + /// Called when the Android system volume slider changes (remote playback). + void _onRemoteVolumeChange(int volume) { + if (_castService.isConnected) { + _castService.setVolume(volume / 100.0); + } else if (_upnpService.isConnected) { + _upnpService.setVolume(volume); + } + } + /// Apply ReplayGain volume adjustment for the current song Future _applyReplayGain(Song? song) async { await _replayGainService.initialize(); @@ -1201,6 +1222,7 @@ class PlayerProvider extends ChangeNotifier { notifyListeners(); if (_castService.isConnected) { _audioPlayer.pause(); // Ensure local is paused + _androidSystemService.setRemotePlayback(isRemote: true, volume: 50); if (_currentSong != null) { // Clear so playSong() does a full load instead of togglePlayPause() final song = _currentSong!; @@ -1209,6 +1231,7 @@ class PlayerProvider extends ChangeNotifier { } } else { // Cast disconnected + _androidSystemService.setRemotePlayback(isRemote: false); _isPlaying = false; notifyListeners(); _updateAndroidAuto(); @@ -1226,6 +1249,11 @@ class PlayerProvider extends ChangeNotifier { _upnpWasConnected = true; _upnpWasPlaying = false; if (_audioPlayer.playing) _audioPlayer.pause(); + final vol = _upnpService.volume; + _androidSystemService.setRemotePlayback( + isRemote: true, + volume: vol >= 0 ? vol : 50, + ); if (_currentSong != null) { // Clear _currentSong so playSong() does a full loadAndPlay on the // renderer instead of short-circuiting into togglePlayPause() (which @@ -1244,6 +1272,7 @@ class PlayerProvider extends ChangeNotifier { _isPlaying = false; _position = Duration.zero; _duration = Duration.zero; + _androidSystemService.setRemotePlayback(isRemote: false); notifyListeners(); _updateAndroidAuto(); return; @@ -1286,6 +1315,12 @@ class PlayerProvider extends ChangeNotifier { changed = true; } + // Sync volume from renderer to Android system volume slider + final vol = _upnpService.volume; + if (vol >= 0) { + _androidSystemService.updateRemoteVolume(vol); + } + if (changed) { notifyListeners(); _updateAndroidAuto(); diff --git a/lib/services/android_auto_service.dart b/lib/services/android_auto_service.dart index 706ae52..f005352 100644 --- a/lib/services/android_auto_service.dart +++ b/lib/services/android_auto_service.dart @@ -27,6 +27,7 @@ class AndroidAutoService { VoidCallback? onSkipPrevious; Function(Duration position)? onSeekTo; Function(String mediaId)? onPlayFromMediaId; + Function(int volume)? onSetVolume; Future>> Function(String albumId)? onGetAlbumSongs; Future>> Function(String artistId)? @@ -84,6 +85,12 @@ class AndroidAutoService { onPlayFromMediaId?.call(mediaId); } break; + case 'setVolume': + final volume = event['volume'] as int?; + if (volume != null) { + onSetVolume?.call(volume); + } + break; case 'getAlbumSongs': final albumId = event['albumId'] as String?; if (albumId != null) { diff --git a/lib/services/android_system_service.dart b/lib/services/android_system_service.dart index 3a05e1f..b672afa 100644 --- a/lib/services/android_system_service.dart +++ b/lib/services/android_system_service.dart @@ -294,6 +294,32 @@ class AndroidSystemService { } } + Future setRemotePlayback({ + required bool isRemote, + int volume = 50, + }) async { + if (defaultTargetPlatform != TargetPlatform.android) return; + try { + await _methodChannel.invokeMethod('setRemotePlayback', { + 'isRemote': isRemote, + 'volume': volume, + }); + } catch (e) { + debugPrint('Error setting remote playback: $e'); + } + } + + Future updateRemoteVolume(int volume) async { + if (defaultTargetPlatform != TargetPlatform.android) return; + try { + await _methodChannel.invokeMethod('updateRemoteVolume', { + 'volume': volume, + }); + } catch (e) { + debugPrint('Error updating remote volume: $e'); + } + } + Future dispose() async { if (defaultTargetPlatform != TargetPlatform.android) return; diff --git a/lib/services/cast_service.dart b/lib/services/cast_service.dart index 002523d..5189dbe 100644 --- a/lib/services/cast_service.dart +++ b/lib/services/cast_service.dart @@ -359,14 +359,12 @@ class CastService extends ChangeNotifier { } } - // Volume control - simplified for compatibility + // Volume control Future setVolume(double volume) async { if (!isConnected) return; - debugPrint( - 'CastService: Volume control via device (${volume.toStringAsFixed(2)})', - ); - // Note: Volume control depends on the Cast SDK implementation - // Some methods may not be available in all versions + volume = volume.clamp(0.0, 1.0); + _sessionManager.setDeviceVolume(volume); + debugPrint('CastService: Volume set to ${volume.toStringAsFixed(2)}'); } @override diff --git a/lib/services/upnp_service.dart b/lib/services/upnp_service.dart index 9509e2c..6593107 100644 --- a/lib/services/upnp_service.dart +++ b/lib/services/upnp_service.dart @@ -20,6 +20,7 @@ class UpnpDevice { final String manufacturer; final String modelName; final String avTransportUrl; // absolute URL for AVTransport control + final String? renderingControlUrl; // absolute URL for RenderingControl (volume) const UpnpDevice({ required this.friendlyName, @@ -27,6 +28,7 @@ class UpnpDevice { required this.manufacturer, required this.modelName, required this.avTransportUrl, + this.renderingControlUrl, }); @override @@ -64,11 +66,13 @@ class UpnpService extends ChangeNotifier { Duration _rendererPosition = Duration.zero; Duration _rendererDuration = Duration.zero; String _rendererState = 'STOPPED'; + int _volume = -1; // -1 = unknown (device may not support RenderingControl) Duration get rendererPosition => _rendererPosition; Duration get rendererDuration => _rendererDuration; String get rendererState => _rendererState; bool get isRendererPlaying => _rendererState == 'PLAYING'; + int get volume => _volume; // 0-100 or -1 if unknown List get devices => List.unmodifiable(_devices); UpnpDevice? get connectedDevice => _connectedDevice; @@ -187,12 +191,15 @@ class UpnpService extends ChangeNotifier { return null; } + final renderingControlUrl = _extractRenderingControlUrl(xml, location); + return UpnpDevice( friendlyName: friendlyName, location: location, manufacturer: manufacturer, modelName: modelName, avTransportUrl: avTransportUrl, + renderingControlUrl: renderingControlUrl, ); } @@ -227,6 +234,26 @@ class UpnpService extends ChangeNotifier { return null; } + /// Find the RenderingControl service's controlURL (for volume control). + static String? _extractRenderingControlUrl(String xml, String location) { + final servicePattern = RegExp( + r'(.*?)', + dotAll: true, + caseSensitive: false, + ); + for (final match in servicePattern.allMatches(xml)) { + final serviceBlock = match.group(1) ?? ''; + final serviceType = _xmlText(serviceBlock, 'serviceType') ?? ''; + if (serviceType.toLowerCase().contains('renderingcontrol')) { + final controlPath = _xmlText(serviceBlock, 'controlURL'); + if (controlPath == null) continue; + final base = Uri.parse(location); + return base.resolve(controlPath).toString(); + } + } + return null; + } + // --- Connection --- /// Connects to [device] after verifying reachability via a GetTransportInfo @@ -239,6 +266,10 @@ class UpnpService extends ChangeNotifier { await _soap(device.avTransportUrl, 'GetTransportInfo', ''); _connectedDevice = device; debugPrint('UPnP: Connected to ${device.friendlyName}'); + // Fetch initial volume if RenderingControl is available + if (device.renderingControlUrl != null) { + _volume = await getVolume(); + } _startPolling(); notifyListeners(); return true; @@ -256,6 +287,7 @@ class UpnpService extends ChangeNotifier { _rendererState = 'STOPPED'; _rendererPosition = Duration.zero; _rendererDuration = Duration.zero; + _volume = -1; notifyListeners(); // Fire-and-forget Stop so the renderer actually stops playback @@ -281,12 +313,14 @@ class UpnpService extends ChangeNotifier { } bool _isPolling = false; + int _pollCount = 0; Future _poll() async { if (_isPolling) return; // prevent overlapping polls final device = _connectedDevice; if (device == null) return; _isPolling = true; + _pollCount++; try { final state = await getPlaybackState(); @@ -305,6 +339,16 @@ class UpnpService extends ChangeNotifier { _rendererDuration = state.duration; changed = true; } + + // Poll volume every 5 seconds (every 5th poll) if device supports it + if (device.renderingControlUrl != null && _pollCount % 5 == 0) { + final vol = await getVolume(); + if (vol >= 0 && vol != _volume) { + _volume = vol; + changed = true; + } + } + if (changed) { notifyListeners(); } @@ -619,6 +663,81 @@ class UpnpService extends ChangeNotifier { return responseBody; } + /// SOAP query against the RenderingControl service (for volume). + Future _renderingQuery(String action, String body) async { + final device = _connectedDevice; + if (device?.renderingControlUrl == null) { + throw Exception('No RenderingControl URL'); + } + const serviceType = 'urn:schemas-upnp-org:service:RenderingControl:1'; + final envelope = + '\n' + '\n' + ' \n' + ' \n' + ' 0\n' + ' $body\n' + ' \n' + ' \n' + ''; + + final response = await _dio.post( + device!.renderingControlUrl!, + data: envelope, + options: Options( + headers: { + 'Content-Type': 'text/xml; charset="utf-8"', + 'SOAPAction': '"$serviceType#$action"', + }, + validateStatus: (_) => true, + responseType: ResponseType.plain, + ), + ); + + final status = response.statusCode ?? 0; + final responseBody = response.data ?? ''; + if (status < 200 || status >= 300) { + throw Exception('UPnP RenderingControl $action failed — HTTP $status'); + } + final lowerBody = responseBody.toLowerCase(); + if (lowerBody.contains('') || + lowerBody.contains('') || + lowerBody.contains('')) { + throw Exception('UPnP RenderingControl fault for $action'); + } + return responseBody; + } + + /// Set the renderer's volume (0-100). + Future setVolume(int vol) async { + vol = vol.clamp(0, 100); + try { + await _renderingQuery( + 'SetVolume', + 'Master$vol', + ); + _volume = vol; + notifyListeners(); + } catch (e) { + debugPrint('UPnP: SetVolume failed: $e'); + } + } + + /// Get the renderer's current volume (0-100). Returns -1 on failure. + Future getVolume() async { + try { + final xml = await _renderingQuery( + 'GetVolume', + 'Master', + ); + final val = _xmlText(xml, 'CurrentVolume'); + return val != null ? int.tryParse(val) ?? -1 : -1; + } catch (_) { + return -1; + } + } + /// DIDL-Lite metadata for the renderer's Now Playing display. /// /// Returns an already-XML-escaped DIDL string, safe to embed as the text diff --git a/lib/widgets/cast_button.dart b/lib/widgets/cast_button.dart index 84c74b4..480abd4 100644 --- a/lib/widgets/cast_button.dart +++ b/lib/widgets/cast_button.dart @@ -296,16 +296,51 @@ class CastButton extends StatelessWidget { ), const SizedBox(height: 16), ], - Text( - 'Playback is being sent to this DLNA device. ' - 'Use Musly\'s player controls to manage playback.', - style: TextStyle( - fontSize: 13, - color: isDark - ? AppTheme.darkSecondaryText - : AppTheme.lightSecondaryText, - ), - textAlign: TextAlign.center, + Consumer( + builder: (context, us, _) { + if (us.volume < 0) { + // Device doesn't support RenderingControl + return Text( + 'Playback is being sent to this DLNA device. ' + 'Use Musly\'s player controls to manage playback.', + style: TextStyle( + fontSize: 13, + color: isDark + ? AppTheme.darkSecondaryText + : AppTheme.lightSecondaryText, + ), + textAlign: TextAlign.center, + ); + } + return Row( + children: [ + Icon( + us.volume == 0 + ? Icons.volume_off + : us.volume < 50 + ? Icons.volume_down + : Icons.volume_up, + color: AppTheme.appleMusicRed, + ), + Expanded( + child: Slider( + value: (us.volume / 100.0).clamp(0.0, 1.0), + onChanged: (v) => us.setVolume((v * 100).round()), + activeColor: AppTheme.appleMusicRed, + ), + ), + Text( + '${us.volume}%', + style: TextStyle( + fontSize: 14, + color: isDark + ? AppTheme.darkSecondaryText + : AppTheme.lightSecondaryText, + ), + ), + ], + ); + }, ), ], ), From 91746937f5337eb0ef21cd3513a869d0595faaae Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Sat, 28 Feb 2026 01:02:59 +0000 Subject: [PATCH 8/9] Fix RepeatMode collision in desktop_player_bar.dart for Flutter 3.41.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as f2233ba but for desktop_player_bar.dart — hide RepeatMode from flutter/material.dart to avoid collision with player_provider's RepeatMode. --- lib/widgets/desktop_player_bar.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/desktop_player_bar.dart b/lib/widgets/desktop_player_bar.dart index fe44804..e175a7f 100644 --- a/lib/widgets/desktop_player_bar.dart +++ b/lib/widgets/desktop_player_bar.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide RepeatMode; import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; import '../models/song.dart'; From ab4ebf73c2d31a127eec401843a94fd5f77590af Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Sat, 28 Feb 2026 06:43:10 +0000 Subject: [PATCH 9/9] Fix UPnP volume bouncing: sync _volume with renderer on connect/poll The player_provider's _volume (0.0-1.0 double) was never synced with the UPnP renderer's actual volume. When connected to DLNA, the stale local phone volume (e.g. 0.95) would leak back to the renderer via Android system volume callbacks, causing the volume to oscillate between the user's intended value and the old stored local value. Fix: on UPnP connect, set _volume to the renderer's current volume. During polling, keep _volume in sync with any external volume changes (e.g. from CEC/TV remote or other control points). --- lib/providers/player_provider.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/providers/player_provider.dart b/lib/providers/player_provider.dart index 716395f..48996f1 100644 --- a/lib/providers/player_provider.dart +++ b/lib/providers/player_provider.dart @@ -1250,6 +1250,8 @@ class PlayerProvider extends ChangeNotifier { _upnpWasPlaying = false; if (_audioPlayer.playing) _audioPlayer.pause(); final vol = _upnpService.volume; + // Sync local volume with renderer so stale local value doesn't leak back + if (vol >= 0) _volume = vol / 100.0; _androidSystemService.setRemotePlayback( isRemote: true, volume: vol >= 0 ? vol : 50, @@ -1315,9 +1317,14 @@ class PlayerProvider extends ChangeNotifier { changed = true; } - // Sync volume from renderer to Android system volume slider + // Sync volume from renderer to both local state and Android system slider final vol = _upnpService.volume; if (vol >= 0) { + final normalized = vol / 100.0; + if ((_volume - normalized).abs() > 0.005) { + _volume = normalized; + changed = true; + } _androidSystemService.updateRemoteVolume(vol); }