Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 78 additions & 28 deletions lib/providers/player_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,16 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver {
double _volume = 1.0;
double _lastNonZeroVolume = 1.0;

bool _isRenderingRemotely = false;
/// True when audio renders on another device rather than this phone.
///
/// Derived rather than stored. As a bool assigned from eight places, a single
/// stale write sent `skipNext()` down its local branch (UI advanced, renderer
/// kept playing) and made `_updateAndroidAuto()` skip the media-session
/// update (frozen notification, pause dead over DLNA). Radio is the one real
/// exception and reuses the existing [_isPlayingRadio] flag.
bool get _isRenderingRemotely =>
!_isPlayingRadio &&
(_castService.isConnected || _upnpService.isConnected);

String? _resolvedArtworkUrl;

Expand Down Expand Up @@ -1851,7 +1860,6 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver {
}
}

_isRenderingRemotely = true;
_isPlaying = success;
_isLoading = false;
if (initialPosition != null && initialPosition > Duration.zero) {
Expand All @@ -1863,15 +1871,28 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver {
_updateAndroidAuto();
return;
} else if (_upnpService.isConnected) {
// Claim this switch. Each await below lets another press start a
// second pipeline; without a token an older one's Stop can land after
// a newer one's Play, leaving the renderer on a track nobody asked for.
final switchGeneration = ++_remoteSwitchGeneration;
bool superseded() {
if (switchGeneration == _remoteSwitchGeneration) return false;
debugPrint('UPnP: switch #$switchGeneration superseded by '
'#$_remoteSwitchGeneration — abandoning "${song.title}"');
return true;
}

_upnpWasPlaying = false;
debugPrint(
'UPnP: playSong() taking UPnP branch, isConnected=${_upnpService.isConnected}',
);
if (_audioPlayer.playing) await _audioPlayer.stop();
if (superseded()) return;

final playUrl = song.isLocal == true && song.path != null
? Uri.file(song.path!).toString()
: await _subsonicService.resolveStreamUrlAsync(song);
if (superseded()) return;

try {
final mimeType =
Expand All @@ -1887,19 +1908,24 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver {
durationSecs: song.duration,
contentType: mimeType,
);
if (superseded()) return;
if (!success) {
_upnpService.disconnect();
debugPrint(
'UPnP playback failed (retries exhausted), disconnected');
return;
}
} catch (e) {
// A superseded switch must not tear down the connection a newer one
// is using.
if (superseded()) return;
_upnpService.disconnect();
debugPrint('UPnP playback failed, disconnected: $e');
rethrow;
}
_currentUpnpTrackUrl = playUrl;
_isRenderingRemotely = true;
_currentUpnpTrackUrl = UpnpService.canonicalUri(playUrl);
// Anything pre-queued belonged to the track we just replaced.
_nextUpnpTrackUrl = null;
_isPlaying = true;
_isLoading = false;
if (initialPosition != null && initialPosition > Duration.zero) {
Expand All @@ -1916,7 +1942,6 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver {
}
return;
} else {
_isRenderingRemotely = false;

final youtubeSource = song.isLocal != true
? await _subsonicService.getYoutubeAudioSource(song)
Expand Down Expand Up @@ -2057,7 +2082,6 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver {
_queue = [];
_currentIndex = -1;
_isPlayingRadio = true;
_isRenderingRemotely = false;
_currentRadioStation = station;
_position = Duration.zero;
_duration = Duration.zero;
Expand Down Expand Up @@ -3297,7 +3321,6 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver {
if (connected && !_castWasConnected) {
_castWasConnected = true;
_castWasPlaying = false;
_isRenderingRemotely = true;
if (_audioPlayer.playing) _audioPlayer.pause();
final vol = _castService.mediaState.volume;
if (vol >= 0) {
Expand All @@ -3319,7 +3342,6 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver {
if (!connected && _castWasConnected) {
_castWasConnected = false;
_castWasPlaying = false;
_isRenderingRemotely = false;
_isPlaying = false;
_audioHandler.setRemotePlayback(isRemote: false);
notifyListeners();
Expand Down Expand Up @@ -3380,7 +3402,15 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver {

bool _upnpWasConnected = false;
bool _upnpWasPlaying = false;
/// Canonical URIs of the track the renderer is playing and the one pre-queued
/// via SetNextAVTransportURI. Canonical because renderers echo URIs back with
/// different escaping than we sent — see [UpnpService.canonicalUri].
/// Identifies the most recent remote switch so slower in-flight ones can
/// detect they were overtaken.
int _remoteSwitchGeneration = 0;

String? _currentUpnpTrackUrl;
String? _nextUpnpTrackUrl;

final bool _isA2dpAudioActive = false;

Expand All @@ -3403,6 +3433,8 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver {
durationSecs: nextSong.duration,
contentType: mimeType,
);
// Lets the poll recognise a genuine gapless auto-advance.
_nextUpnpTrackUrl = UpnpService.canonicalUri(nextUrl);
} catch (e) {
debugPrint('UPnP: Failed to set next URI: $e');
}
Expand Down Expand Up @@ -3434,7 +3466,7 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver {
_upnpWasConnected = false;
_upnpWasPlaying = false;
_currentUpnpTrackUrl = null;
_isRenderingRemotely = false;
_nextUpnpTrackUrl = null;
_isPlaying = false;

_audioHandler.setRemotePlayback(isRemote: false);
Expand All @@ -3461,26 +3493,44 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver {
return;
}

final currentTrackUri = _upnpService.currentTrackUri;
if (_currentUpnpTrackUrl != null &&
currentTrackUri != null &&
currentTrackUri.isNotEmpty &&
currentTrackUri != _currentUpnpTrackUrl &&
_currentIndex + 1 < _queue.length) {
debugPrint(
'UPnP: Renderer switched track to $currentTrackUri — advancing index');
_upnpWasPlaying = playing;
_currentIndex++;
_currentSong = _queue[_currentIndex];
_currentUpnpTrackUrl = currentTrackUri;
notifyListeners();
_updateAllServices();
_saveQueueState();
if (_currentIndex + 1 < _queue.length) {
_queueNextSongForUpnp(_queue[_currentIndex + 1]).catchError((_) {});
// Follow a gapless auto-advance on the renderer.
//
// This used to compare a decoded URI against an undecoded one — never equal
// — and respond to any difference with a blind `_currentIndex++`, which
// walked the UI up the queue a track per second while the speaker stayed
// put. Now only the transition we actually queued via
// SetNextAVTransportURI is accepted; anything else is left alone.
final rendererUri = _upnpService.currentTrackUri;
if (rendererUri != null && rendererUri.isNotEmpty) {
final canonical = UpnpService.canonicalUri(rendererUri);
final isNext = _nextUpnpTrackUrl != null &&
canonical == _nextUpnpTrackUrl &&
_currentIndex + 1 < _queue.length;

if (isNext) {
debugPrint('UPnP: renderer auto-advanced to queued next track '
'— following to index ${_currentIndex + 1}');
_upnpWasPlaying = playing;
_currentIndex++;
_currentSong = _queue[_currentIndex];
_currentUpnpTrackUrl = canonical;
_nextUpnpTrackUrl = null;
_position = Duration.zero;
notifyListeners();
_updateAndroidAuto();
_saveQueueState();
if (_currentIndex + 1 < _queue.length) {
_queueNextSongForUpnp(_queue[_currentIndex + 1]).catchError((_) {});
}
_checkAndRefillAutoQueue().catchError((_) {});
return;
}

if (_currentUpnpTrackUrl != null && canonical != _currentUpnpTrackUrl) {
// Another controller, or a switch of ours still in flight. Never guess.
debugPrint('UPnP: renderer on an unrecognised track — leaving queue '
'position alone (was index $_currentIndex)');
}
_checkAndRefillAutoQueue().catchError((_) {});
return;
}

_upnpWasPlaying = playing;
Expand Down
26 changes: 25 additions & 1 deletion lib/services/audio_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,35 @@ class MuslyAudioHandler extends BaseAudioHandler with SeekHandler {
static const _remoteMaxVolume = 100;
static const _remoteVolumeStep = 5;

StreamSubscription<PlaybackEvent>? _localStateSub;

MuslyAudioHandler() {
_player.playbackEventStream.map(_buildPlaybackState).pipe(playbackState);
// listen()+add() rather than pipe(): pipe() is addStream() on the rxdart
// Subject, which makes every other playbackState.add() in this class throw
// "You cannot add items while items are being added from addStream". That
// silently broke updateRemotePlaybackState(), so the media session could
// never follow Cast/DLNA — notification, lock screen and head-unit controls
// stayed pinned to the idle local player and pause did nothing. The gate
// stops that idle player from overwriting remote state.
_localStateSub = _player.playbackEventStream.listen((event) {
if (_remotePlayback) return;
playbackState.add(_buildPlaybackState(event));
});

if (!kIsWeb && Platform.isAndroid) {
androidPlaybackInfo.add(LocalAndroidPlaybackInfo());
}
}

/// True while local player events are still being mirrored into the session.
bool get isMirroringLocalState => _localStateSub != null;

/// Stop mirroring the local player into the media session.
Future<void> cancelLocalStateMirror() async {
await _localStateSub?.cancel();
_localStateSub = null;
}

@override
Future<void> play() => onPlay?.call() ?? _player.play();

Expand Down Expand Up @@ -503,6 +524,9 @@ class MuslyAudioHandler extends BaseAudioHandler with SeekHandler {
@override
Future<void> customAction(String name, [Map<String, dynamic>? extras]) async {
if (name == 'dispose') {
// Before _player.dispose(): AudioPlayer.dispose() does not cancel our
// own subscription to its event stream.
await cancelLocalStateMirror();
for (final sub in _childrenSubjects.values) {
await sub.close();
}
Expand Down
66 changes: 65 additions & 1 deletion lib/services/upnp_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,62 @@ class UpnpService extends ChangeNotifier {

static String? _xmlText(String xml, String tag) {
final pattern = RegExp('<$tag>([^<]*)</$tag>', caseSensitive: false);
return pattern.firstMatch(xml)?.group(1)?.trim();
final raw = pattern.firstMatch(xml)?.group(1)?.trim();
return raw == null ? null : decodeXmlEntities(raw);
}

/// Decode one layer of XML character entities, named or numeric.
///
/// Everything denoting `&` is decoded last, together, so `&amp;lt;` yields
/// the literal `&lt;` the sender meant rather than collapsing to `<`.
/// Renderers are inconsistent about which form they emit, so `&#38;` and
/// `&#x26;` have to be understood as well as `&amp;`.
static String decodeXmlEntities(String input) {
var out = input
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'");

// Numeric references, except those denoting '&' — those wait for the final
// step below so they cannot re-form a named entity mid-pass.
out = out.replaceAllMapped(
RegExp(r'&#([xX][0-9a-fA-F]+|[0-9]+);'),
(m) {
final ref = m.group(1)!;
final isHex = ref.startsWith('x') || ref.startsWith('X');
final code = isHex
? int.tryParse(ref.substring(1), radix: 16)
: int.tryParse(ref);
// Leave malformed, out-of-range and '&' references untouched.
if (code == null || code == 0x26 || code < 0x20 || code > 0x10FFFF) {
return m.group(0)!;
}
return String.fromCharCode(code);
},
);

return out
.replaceAll('&amp;', '&')
.replaceAllMapped(RegExp(r'&#(0*38|[xX]0*26);'), (_) => '&');
}

/// Canonical form for comparing two URIs, never for reconstructing one.
///
/// Renderers echo a URI back through more escaping layers than we sent it
/// through (SOAP envelope, then embedded DIDL-Lite), so `...&v=1.16.1...`
/// returns as `...&amp;amp;v=1.16.1...`. Since the depth is not knowable in
/// advance this decodes to a fixed point — more aggressive than XML
/// semantics, but applied to both sides of every comparison, so equal tracks
/// still match and different ones still differ. Bounded so it cannot spin.
static String canonicalUri(String uri) {
var out = uri.trim();
for (var i = 0; i < 5; i++) {
final next = decodeXmlEntities(out);
if (next == out) break;
out = next;
}
return out;
}

static String? _extractAvTransportUrl(String xml, String location) {
Expand Down Expand Up @@ -309,6 +364,15 @@ class UpnpService extends ChangeNotifier {

try {
final state = await getPlaybackState();

// Low-rate heartbeat: a healthy poll was previously silent, so a
// renderer drifting out of sync left no trace in the logs at all.
if (_pollCount % 30 == 1) {
debugPrint('UPnP: poll #$_pollCount healthy — '
'state=${state?.transportState ?? "null"} '
'pos=${state?.position.inSeconds ?? -1}s errs=$_consecutivePollErrors');
}

if (state == null) {
_consecutivePollErrors++;
_safeNotifyListeners();
Expand Down
Loading