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';