Skip to content
Merged
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
8 changes: 7 additions & 1 deletion android/app/src/main/kotlin/com/musly/musly/MusicService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,13 @@ class MusicService : MediaBrowserServiceCompat() {
}

override fun onAdjustVolume(direction: Int) {
val newVolume = (currentVolume + direction * 5).coerceIn(0, 100)
// Use getCurrentVolume() (the live VolumeProviderCompat
// property) not the captured constructor param. The param
// is a val fixed at connection time, so every relative
// adjustment would always compute from the same baseline,
// causing the volume to snap back to initialVolume±5 no
// matter how many times the user presses the button.
val newVolume = (getCurrentVolume() + direction * 5).coerceIn(0, 100)
setCurrentVolume(newVolume)
AndroidAutoPlugin.sendCommand("setVolume", mapOf("volume" to newVolume))
}
Expand Down
97 changes: 82 additions & 15 deletions lib/providers/player_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ class PlayerProvider extends ChangeNotifier {
Song? _currentSong;
double _volume = 1.0;

/// True only while audio is actually being rendered on a remote device.
/// Distinct from isConnected: if the user plays a radio station while a
/// UPnP renderer is connected, the audio is still local, so this stays false.
bool _isRenderingRemotely = false;

String? _resolvedArtworkUrl;

RadioStation? _currentRadioStation;
Expand Down Expand Up @@ -149,19 +154,28 @@ class PlayerProvider extends ChangeNotifier {
_windowsService.onSkipPrevious = skipPrevious;
_windowsService.onSeekTo = seek;

// Audio focus and noisy callbacks must be no-ops in remote-playback mode.
// The audio is playing on the renderer device, not on this phone, so
// Android reassigning audio focus at screen-off (or a noisy event) must
// not pause the renderer.
_androidSystemService.onAudioFocusLoss = () {
if (isRemotePlayback) return;
pause();
};
_androidSystemService.onAudioFocusLossTransient = () {
if (isRemotePlayback) return;
pause();
};
_androidSystemService.onAudioFocusLossTransientCanDuck = () {
if (isRemotePlayback) return;
_audioPlayer.setVolume(0.3);
};
_androidSystemService.onAudioFocusGain = () {
if (isRemotePlayback) return;
_audioPlayer.setVolume(_volume);
};
_androidSystemService.onBecomingNoisy = () {
if (isRemotePlayback) return;
pause();
};

Expand Down Expand Up @@ -671,6 +685,11 @@ class PlayerProvider extends ChangeNotifier {
int get currentIndex => _currentIndex;
bool get isPlaying => _isPlaying;
bool get isLoading => _isLoading;
/// True when audio is playing on a remote renderer (UPnP or Cast) rather
/// than locally. Used to suppress audio-focus and noisy-event handling that
/// would incorrectly pause the remote device, and to route UI volume changes
/// to the renderer instead of the Android system volume.
bool get isRemotePlayback => _isRenderingRemotely;
bool get shuffleEnabled => _shuffleEnabled;
RepeatMode get repeatMode => _repeatMode;
Duration get position => _position;
Expand All @@ -683,7 +702,19 @@ class PlayerProvider extends ChangeNotifier {
RadioStation? get currentRadioStation => _currentRadioStation;
bool get isPlayingRadio => _isPlayingRadio;

Stream<Duration> get positionStream => _audioPlayer.positionStream;
// Unified position stream: fed by the local audio player in normal mode, or
// by UPnP/Cast polling in remote-playback mode. The UI subscribes to this
// instead of directly to _audioPlayer.positionStream so that the progress
// bar animates correctly regardless of which playback path is active.
final _positionController = StreamController<Duration>.broadcast();
Stream<Duration> get positionStream => _positionController.stream;

// Subscriptions stored so they can be cancelled before dispose closes the
// StreamController, preventing a late just_audio tick from calling add() on
// a closed controller.
StreamSubscription<PlayerState>? _playerStateSub;
StreamSubscription<Duration>? _positionSub;
StreamSubscription<Duration?>? _durationSub;

double get progress {
if (_duration.inMilliseconds == 0) return 0;
Expand Down Expand Up @@ -777,8 +808,12 @@ class PlayerProvider extends ChangeNotifier {
notifyListeners();
});

_audioPlayer.playerStateStream.listen(
_playerStateSub = _audioPlayer.playerStateStream.listen(
(state) {
// In remote-playback mode the local player is stopped/paused; ignore
// its state so it doesn't overwrite the UPnP/Cast-managed values.
if (_isRenderingRemotely) return;

final wasPlaying = _isPlaying;
_isPlaying = state.playing;

Expand Down Expand Up @@ -808,14 +843,18 @@ class PlayerProvider extends ChangeNotifier {

Duration? lastNotified;
Duration? lastSystemUpdate;
_audioPlayer.positionStream.listen(
_positionSub = _audioPlayer.positionStream.listen(
(position) {

// In remote-playback mode the local player sits idle at position zero;
// ignore its ticks so they don't overwrite the UPnP/Cast position.
if (_isRenderingRemotely) return;

final positionJumpedBack =
_position.inMilliseconds > 0 &&
position.inMilliseconds < _position.inMilliseconds - 1000;

_position = position;
_positionController.add(position);

if (positionJumpedBack ||
lastNotified == null ||
Expand All @@ -836,11 +875,15 @@ class PlayerProvider extends ChangeNotifier {
},
);

_audioPlayer.durationStream.listen(
_durationSub = _audioPlayer.durationStream.listen(
(duration) {
// In remote-playback mode the local player has no loaded track; ignore
// its duration so it doesn't zero out the UPnP/Cast duration.
if (_isRenderingRemotely) return;

_duration = duration ?? Duration.zero;
notifyListeners();
_updateAndroidAuto();
_updateAndroidAuto();
},
onError: (error) {
debugPrint('Duration stream error (can be ignored): $error');
Expand Down Expand Up @@ -963,9 +1006,12 @@ class PlayerProvider extends ChangeNotifier {
: null,
autoPlay: true,
);
_isRenderingRemotely = true;
_isPlaying = true;
} else if (_upnpService.isConnected) {

// Reset before sending Stop so a poll that fires mid-load can't
// mistake the STOPPED state for a natural track end and advance twice.
_upnpWasPlaying = false;
debugPrint(
'UPnP: playSong() taking UPnP branch, isConnected=${_upnpService.isConnected}',
);
Expand All @@ -976,6 +1022,10 @@ class PlayerProvider extends ChangeNotifier {
: _subsonicService.getStreamUrl(song.id);

try {
// Resolve the MIME type so strict UPnP renderers (e.g. moode /
// upmpdcli with "check metadata" on) can validate protocolInfo.
final mimeType = song.contentType ??
UpnpService.mimeTypeFromSuffix(song.suffix);
final success = await _upnpService.loadAndPlay(
url: playUrl,
title: song.title,
Expand All @@ -985,6 +1035,7 @@ class PlayerProvider extends ChangeNotifier {
? _subsonicService.getCoverArtUrl(song.coverArt, size: 0)
: null,
durationSecs: song.duration,
contentType: mimeType,
);
if (!success) {
_upnpService.disconnect();
Expand All @@ -997,9 +1048,10 @@ class PlayerProvider extends ChangeNotifier {
debugPrint('UPnP playback failed, disconnected: $e');
rethrow;
}
_isRenderingRemotely = true;
_isPlaying = true;
} else {

_isRenderingRemotely = false;
final String playUrl;
if (song.isLocal == true && song.path != null) {
playUrl = Uri.file(song.path!).toString();
Expand Down Expand Up @@ -1077,6 +1129,7 @@ class PlayerProvider extends ChangeNotifier {
_queue = [];
_currentIndex = -1;
_isPlayingRadio = true;
_isRenderingRemotely = false; // radio always plays locally
_currentRadioStation = station;
_position = Duration.zero;
_duration = Duration.zero;
Expand Down Expand Up @@ -1180,11 +1233,12 @@ class PlayerProvider extends ChangeNotifier {
if (_castService.isConnected) {
await _castService.stop();
} else if (_upnpService.isConnected) {
_upnpWasPlaying = false; // prevent poll from misreading the STOPPED state
await _upnpService.stop();
} else {
await _audioPlayer.stop();
}

_isPlaying = false;
_position = Duration.zero;
notifyListeners();
Expand Down Expand Up @@ -1506,6 +1560,10 @@ class PlayerProvider extends ChangeNotifier {
_samsungService.dispose();

_discordRpcService.shutdown();
_playerStateSub?.cancel();
_positionSub?.cancel();
_durationSub?.cancel();
_positionController.close();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
super.dispose();
}

Expand Down Expand Up @@ -1584,7 +1642,7 @@ class PlayerProvider extends ChangeNotifier {
playSong(song);
}
} else {

_isRenderingRemotely = false;
_androidSystemService.setRemotePlayback(isRemote: false);
_isPlaying = false;
notifyListeners();
Expand Down Expand Up @@ -1621,6 +1679,7 @@ class PlayerProvider extends ChangeNotifier {
if (!connected && _upnpWasConnected) {
_upnpWasConnected = false;
_upnpWasPlaying = false;
_isRenderingRemotely = false;
_isPlaying = false;
_position = Duration.zero;
_duration = Duration.zero;
Expand All @@ -1637,10 +1696,12 @@ class PlayerProvider extends ChangeNotifier {
final playing = _upnpService.isRendererPlaying;
final rendererState = _upnpService.rendererState;

if (_upnpWasPlaying &&
rendererState == 'STOPPED' &&
dur > Duration.zero &&
pos.inSeconds >= dur.inSeconds - 1) {
if (_upnpWasPlaying && rendererState == 'STOPPED') {
// _upnpWasPlaying is reset to false in playSong() and stop() before
// any Stop command is sent, so this only fires for a *natural* track
// end. We don't check duration > 0 here because many renderers
// (including moode/upmpdcli) return 0:00:00 from GetPositionInfo once
// the transport is stopped, which would cause the check to silently fail.
debugPrint('UPnP: Track ended (pos=${pos.inSeconds}s, dur=${dur.inSeconds}s) — advancing');
_upnpWasPlaying = false;
_onSongComplete();
Expand Down Expand Up @@ -1670,11 +1731,17 @@ class PlayerProvider extends ChangeNotifier {
if ((_volume - normalized).abs() > 0.005) {
_volume = normalized;
changed = true;
// Only push the new volume to the Android MediaSession when it has
// actually changed. Calling updateRemoteVolume unconditionally on
// every state tick would overwrite any in-flight physical volume
// adjustment with the stale cached value before the next poll cycle
// had a chance to read it back, causing the audible snap-back.
_androidSystemService.updateRemoteVolume(vol);
}
_androidSystemService.updateRemoteVolume(vol);
}

if (changed) {
_positionController.add(_position);
notifyListeners();
_updateAndroidAuto();
}
Expand Down
Loading