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..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,9 @@ 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
import io.flutter.plugin.common.EventChannel
@@ -9,14 +12,16 @@ 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"
-
+
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
@@ -29,13 +34,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,10 +70,21 @@ 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
-
- MusicService.getInstance()?.updatePlaybackState(
- songId, title, artist, album, artworkUrl, duration, position, playing
- )
+
+ // 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()
+ }
result.success(null)
}
"updateRecentSongs" -> {
@@ -113,11 +129,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..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
@@ -122,10 +123,20 @@ 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
-
- MusicService.getInstance()?.updatePlaybackState(
- songId, title, artist, album, artworkUrl, duration, position, playing
- )
+
+ // 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()
+ }
result.success(null)
}
"setNotificationColor" -> {
@@ -156,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 4070542..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,12 +18,16 @@ 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
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"
@@ -54,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()
@@ -72,16 +77,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() {
@@ -633,14 +640,46 @@ 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
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 a4808a2..48996f1 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();
@@ -179,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;
@@ -327,17 +329,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();
}
@@ -623,11 +614,21 @@ 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',
+ album: song.album,
+ albumArtUrl: song.coverArt != null
+ ? _subsonicService.getCoverArtUrl(song.coverArt, size: 0)
+ : 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();
@@ -784,10 +785,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();
}
@@ -798,10 +801,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();
}
@@ -1007,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();
@@ -1079,6 +1099,8 @@ class PlayerProvider extends ChangeNotifier {
@override
void dispose() {
+ _castService.removeListener(_onCastStateChanged);
+ _upnpService.removeListener(_onUpnpStateChanged);
_audioPlayer.dispose();
_androidAutoService.dispose();
_androidSystemService.dispose();
@@ -1200,13 +1222,115 @@ class PlayerProvider extends ChangeNotifier {
notifyListeners();
if (_castService.isConnected) {
_audioPlayer.pause(); // Ensure local is paused
+ _androidSystemService.setRemotePlayback(isRemote: true, volume: 50);
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
+ _androidSystemService.setRemotePlayback(isRemote: false);
+ _isPlaying = false;
+ notifyListeners();
+ _updateAndroidAuto();
+ }
+ }
+
+ 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();
+ 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,
+ );
+ if (_currentSong != null) {
+ // 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;
+ }
+
+ // 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;
+ _androidSystemService.setRemotePlayback(isRemote: false);
notifyListeners();
+ _updateAndroidAuto();
+ 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) {
+ _duration = dur;
+ changed = true;
+ }
+ if (playing != _isPlaying) {
+ _isPlaying = playing;
+ changed = true;
+ }
+
+ // 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);
+ }
+
+ 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/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 09a7d58..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,12 +28,25 @@ class UpnpDevice {
required this.manufacturer,
required this.modelName,
required this.avTransportUrl,
+ this.renderingControlUrl,
});
@override
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 +60,19 @@ 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';
+ 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;
@@ -164,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,
);
}
@@ -204,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
@@ -211,9 +261,16 @@ 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}');
+ // Fetch initial volume if RenderingControl is available
+ if (device.renderingControlUrl != null) {
+ _volume = await getVolume();
+ }
+ _startPolling();
notifyListeners();
return true;
} catch (e) {
@@ -223,9 +280,118 @@ 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;
+ _volume = -1;
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 ---
+
+ void _startPolling() {
+ _stopPolling();
+ _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _poll());
+ }
+
+ void _stopPolling() {
+ _pollTimer?.cancel();
+ _pollTimer = null;
+ }
+
+ 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();
+ 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;
+ }
+
+ // 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();
+ }
+ } catch (e) {
+ debugPrint('UPnP: poll error: $e');
+ } finally {
+ _isPolling = false;
+ }
+ }
+
+ /// 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 ---
@@ -237,7 +403,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) {
@@ -257,11 +425,15 @@ 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, 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 +443,56 @@ 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));
+ // 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…');
+ }
- debugPrint('UPnP: Play…');
- await _soap(device.avTransportUrl, 'Play', '1');
- debugPrint('UPnP: Playing "$title" on ${device.friendlyName}');
- return true;
+ // 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);
+
+ // 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 (attempt $attempt)');
+ delay = delay * 2 < const Duration(milliseconds: 2400)
+ ? delay * 2
+ : const Duration(milliseconds: 2400);
+ continue;
+ }
+ } catch (_) {
+ // Can't query state — try Play anyway.
+ }
+
+ try {
+ await _soap(device.avTransportUrl, 'Play', '1');
+ debugPrint('UPnP: Playing "$title" on ${device.friendlyName} (attempt $attempt)');
+ return true;
+ } catch (e) {
+ debugPrint('UPnP: Play attempt $attempt/$maxAttempts failed: $e');
+ if (attempt == maxAttempts) return false;
+ delay = delay * 2 < const Duration(milliseconds: 2400)
+ ? delay * 2
+ : const Duration(milliseconds: 2400);
+ }
+ }
+ return false;
}
Future pause() async {
@@ -327,6 +542,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 +617,128 @@ 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,
+ ),
+ );
+
+ final status = response.statusCode ?? 0;
+ final responseBody = response.data ?? '';
+ if (status < 200 || status >= 300) {
+ throw Exception('UPnP SOAP $action failed — HTTP $status');
+ }
+ final lowerBody = responseBody.toLowerCase();
+ if (lowerBody.contains('') ||
+ lowerBody.contains('') ||
+ lowerBody.contains('')) {
+ throw Exception('UPnP SOAP fault for $action');
+ }
+ 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
/// content of `` in the SOAP body.
@@ -409,6 +746,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 +760,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 +805,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();
}
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,
+ ),
+ ),
+ ],
+ );
+ },
),
],
),
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';