Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73ca6f230e | ||
|
|
d0ab9ff618 | ||
|
|
0989b322a2 | ||
|
|
433eacfc43 | ||
|
|
e24df29239 | ||
|
|
2736b4478a | ||
|
|
020a440f11 | ||
|
|
2e7682facd |
@@ -416,6 +416,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
await refreshPlayers();
|
await refreshPlayers();
|
||||||
await _updatePlayerState();
|
await _updatePlayerState();
|
||||||
|
// Preload all player tracks to ensure fresh data after app resume
|
||||||
|
await _preloadAdjacentPlayers(preloadAll: true);
|
||||||
_logger.log('🔄 Connection verified, players and state refreshed');
|
_logger.log('🔄 Connection verified, players and state refreshed');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('🔄 Connection verification failed, reconnecting: $e');
|
_logger.log('🔄 Connection verification failed, reconnecting: $e');
|
||||||
@@ -581,6 +583,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_sendspinService?.dispose();
|
_sendspinService?.dispose();
|
||||||
_sendspinService = SendspinService(_serverUrl!);
|
_sendspinService = SendspinService(_serverUrl!);
|
||||||
|
|
||||||
|
// Set auth token for MA 2.7.1+ proxy authentication
|
||||||
|
final authToken = await SettingsService.getMaAuthToken();
|
||||||
|
if (authToken != null) {
|
||||||
|
_sendspinService!.setAuthToken(authToken);
|
||||||
|
_logger.log('Sendspin: Auth token set for proxy authentication');
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize PCM audio player for raw audio streaming
|
// Initialize PCM audio player for raw audio streaming
|
||||||
_pcmAudioPlayer?.dispose();
|
_pcmAudioPlayer?.dispose();
|
||||||
_pcmAudioPlayer = PcmAudioPlayer();
|
_pcmAudioPlayer = PcmAudioPlayer();
|
||||||
@@ -1082,23 +1091,53 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse artist from title if artist is missing but title contains " - "
|
||||||
|
var trackTitle = currentMedia['title'] as String? ?? 'Unknown Track';
|
||||||
|
var artistName = currentMedia['artist'] as String?;
|
||||||
|
|
||||||
|
if ((artistName == null || artistName == 'Unknown Artist') && trackTitle.contains(' - ')) {
|
||||||
|
final parts = trackTitle.split(' - ');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
artistName = parts[0].trim();
|
||||||
|
trackTitle = parts.sublist(1).join(' - ').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artistName ??= 'Unknown Artist';
|
||||||
|
|
||||||
final trackFromEvent = Track(
|
final trackFromEvent = Track(
|
||||||
itemId: currentMedia['queue_item_id'] as String? ?? '',
|
itemId: currentMedia['queue_item_id'] as String? ?? '',
|
||||||
provider: 'library',
|
provider: 'library',
|
||||||
name: currentMedia['title'] as String? ?? 'Unknown Track',
|
name: trackTitle,
|
||||||
uri: currentMedia['uri'] as String?,
|
uri: currentMedia['uri'] as String?,
|
||||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||||
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')],
|
artists: [Artist(itemId: '', provider: 'library', name: artistName)],
|
||||||
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
|
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
|
||||||
|
|
||||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
// Only cache if we don't already have better data from queue
|
||||||
_currentTrack = trackFromEvent;
|
final existingTrack = _cacheService.getCachedTrackForPlayer(playerId);
|
||||||
|
final existingHasProperArtist = existingTrack != null &&
|
||||||
|
existingTrack.artistsString != 'Unknown Artist' &&
|
||||||
|
!existingTrack.name.contains(' - ');
|
||||||
|
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
||||||
|
final newHasImage = metadata != null;
|
||||||
|
|
||||||
|
// Keep existing if it has proper artist OR has image that new one lacks
|
||||||
|
final keepExisting = existingHasProperArtist || (existingHasImage && !newHasImage);
|
||||||
|
|
||||||
|
if (!keepExisting) {
|
||||||
|
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
||||||
|
_logger.log('📋 Cached track for $playerName from player_updated: ${trackFromEvent.name}');
|
||||||
|
} else {
|
||||||
|
_logger.log('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('📋 Cached track for $playerName from player_updated: ${trackFromEvent.name}');
|
// For selected player, _updatePlayerState() is already called above which fetches queue data
|
||||||
|
// Only update _currentTrack here if we don't have it yet (initial load)
|
||||||
|
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId && _currentTrack == null) {
|
||||||
|
_currentTrack = trackFromEvent;
|
||||||
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -1749,6 +1788,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
_preloadAdjacentPlayers();
|
_preloadAdjacentPlayers();
|
||||||
|
|
||||||
|
// Immediately fetch fresh track data from queue to avoid showing stale cache
|
||||||
|
// This is important when resuming app after being in background - cached data
|
||||||
|
// from player_updated events may have incomplete artist info
|
||||||
|
await _updatePlayerState();
|
||||||
|
|
||||||
if (!skipNotify) {
|
if (!skipNotify) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,8 +84,16 @@ class SendspinService {
|
|||||||
|
|
||||||
bool _isDisposed = false;
|
bool _isDisposed = false;
|
||||||
|
|
||||||
|
// Auth token for proxy authentication (MA 2.7.1+)
|
||||||
|
String? _authToken;
|
||||||
|
|
||||||
SendspinService(this.serverUrl);
|
SendspinService(this.serverUrl);
|
||||||
|
|
||||||
|
/// Set the MA auth token for proxy authentication
|
||||||
|
void setAuthToken(String? token) {
|
||||||
|
_authToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialize and connect to Sendspin server
|
/// Initialize and connect to Sendspin server
|
||||||
/// Uses persistent player ID and username-based naming
|
/// Uses persistent player ID and username-based naming
|
||||||
Future<bool> connect() async {
|
Future<bool> connect() async {
|
||||||
@@ -105,10 +113,11 @@ class SendspinService {
|
|||||||
bool connected = false;
|
bool connected = false;
|
||||||
|
|
||||||
// Strategy 1: If server is HTTPS, try external wss:// first
|
// Strategy 1: If server is HTTPS, try external wss:// first
|
||||||
|
// Use proxy auth for MA 2.7.1+ - the proxy requires authentication before hello
|
||||||
if (_isHttpsServer()) {
|
if (_isHttpsServer()) {
|
||||||
final externalUrl = _buildExternalSendspinUrl();
|
final externalUrl = _buildExternalSendspinUrl();
|
||||||
_logger.log('Sendspin: Trying external URL: $externalUrl');
|
_logger.log('Sendspin: Trying external URL: $externalUrl');
|
||||||
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 3));
|
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: If external failed or server is HTTP, try local connection
|
// Strategy 2: If external failed or server is HTTP, try local connection
|
||||||
@@ -158,10 +167,11 @@ class SendspinService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Attempt to connect to a specific WebSocket URL
|
/// Attempt to connect to a specific WebSocket URL
|
||||||
Future<bool> _tryConnect(String url, {Duration timeout = const Duration(seconds: 5)}) async {
|
/// If useProxyAuth is true and we have an auth token, authenticate first (for MA 2.7.1+ proxy)
|
||||||
|
Future<bool> _tryConnect(String url, {Duration timeout = const Duration(seconds: 5), bool useProxyAuth = false}) async {
|
||||||
try {
|
try {
|
||||||
// Connect to the base URL without query params - we send player info in hello message
|
// Connect to the base URL without query params - we send player info in hello message
|
||||||
_logger.log('Sendspin: Connecting to $url');
|
_logger.log('Sendspin: Connecting to $url${useProxyAuth ? ' (with proxy auth)' : ''}');
|
||||||
|
|
||||||
// Create WebSocket connection
|
// Create WebSocket connection
|
||||||
final webSocket = await WebSocket.connect(url).timeout(timeout);
|
final webSocket = await WebSocket.connect(url).timeout(timeout);
|
||||||
@@ -176,6 +186,29 @@ class SendspinService {
|
|||||||
onDone: _handleDone,
|
onDone: _handleDone,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If using proxy auth (MA 2.7.1+), authenticate first
|
||||||
|
if (useProxyAuth && _authToken != null) {
|
||||||
|
_logger.log('Sendspin: Sending proxy auth with token');
|
||||||
|
_sendMessage({
|
||||||
|
'type': 'auth',
|
||||||
|
'token': _authToken,
|
||||||
|
}, allowDuringHandshake: true);
|
||||||
|
|
||||||
|
// Wait for auth_ok response
|
||||||
|
final authOk = await _waitForAuth().timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!authOk) {
|
||||||
|
_logger.log('Sendspin: Proxy authentication failed or timed out');
|
||||||
|
await _channel?.sink.close();
|
||||||
|
_channel = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_logger.log('Sendspin: Proxy auth successful, proceeding with hello');
|
||||||
|
}
|
||||||
|
|
||||||
// Send client/hello message immediately after connecting
|
// Send client/hello message immediately after connecting
|
||||||
// This is required by the Sendspin protocol - server waits for this
|
// This is required by the Sendspin protocol - server waits for this
|
||||||
// Message format follows aiosendspin protocol spec
|
// Message format follows aiosendspin protocol spec
|
||||||
@@ -228,12 +261,18 @@ class SendspinService {
|
|||||||
|
|
||||||
/// Wait for server acknowledgment after connection
|
/// Wait for server acknowledgment after connection
|
||||||
Completer<bool>? _ackCompleter;
|
Completer<bool>? _ackCompleter;
|
||||||
|
Completer<bool>? _authCompleter;
|
||||||
|
|
||||||
Future<bool> _waitForAck() async {
|
Future<bool> _waitForAck() async {
|
||||||
_ackCompleter = Completer<bool>();
|
_ackCompleter = Completer<bool>();
|
||||||
return _ackCompleter!.future;
|
return _ackCompleter!.future;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _waitForAuth() async {
|
||||||
|
_authCompleter = Completer<bool>();
|
||||||
|
return _authCompleter!.future;
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle incoming WebSocket messages
|
/// Handle incoming WebSocket messages
|
||||||
/// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data)
|
/// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data)
|
||||||
void _handleMessage(dynamic message) {
|
void _handleMessage(dynamic message) {
|
||||||
@@ -310,6 +349,14 @@ class SendspinService {
|
|||||||
onStreamEnd?.call();
|
onStreamEnd?.call();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'auth_ok':
|
||||||
|
// Proxy authentication successful (MA 2.7.1+)
|
||||||
|
_logger.log('Sendspin: Proxy authentication successful');
|
||||||
|
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||||
|
_authCompleter!.complete(true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'ack':
|
case 'ack':
|
||||||
case 'connected':
|
case 'connected':
|
||||||
case 'registered':
|
case 'registered':
|
||||||
|
|||||||
@@ -1001,7 +1001,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Artist: medium weight, secondary (18px expanded, was 16px)
|
// Artist: medium weight, secondary (18px expanded, was 16px)
|
||||||
// Album: light, tertiary (15px expanded, was 13px)
|
// Album: light, tertiary (15px expanded, was 13px)
|
||||||
final titleFontSize = _lerpDouble(16.0, 24.0, t);
|
final titleFontSize = _lerpDouble(16.0, 24.0, t);
|
||||||
final artistFontSize = _lerpDouble(14.0, 18.0, t); // Increased from 16 to 18
|
final artistFontSize = _lerpDouble(14.0, 18.0, t); // 14px collapsed, 18px expanded
|
||||||
|
|
||||||
final collapsedTitleLeft = _collapsedArtSize + 8; // Reduced from 12 to 8 (4px less)
|
final collapsedTitleLeft = _collapsedArtSize + 8; // Reduced from 12 to 8 (4px less)
|
||||||
final expandedTitleLeft = contentPadding;
|
final expandedTitleLeft = contentPadding;
|
||||||
@@ -1009,7 +1009,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
|
|
||||||
final collapsedTitleTop = (_collapsedHeight - 36) / 2; // Centered vertically (adjusted for increased track/artist gap)
|
final collapsedTitleTop = (_collapsedHeight - 36) / 2; // Centered vertically (adjusted for increased track/artist gap)
|
||||||
|
|
||||||
final collapsedTitleWidth = screenSize.width - _collapsedArtSize - 150;
|
// Controls: 36 (prev) + 34 (play) + 36 (next) + 8 (right margin) = 114px from widget right
|
||||||
|
// For 8px gap: text ends at widgetWidth - 114 - 8 = widgetWidth - 122
|
||||||
|
// Note: collapsedWidth = screenSize.width - 24 (12px margin each side)
|
||||||
|
final collapsedTitleWidth = collapsedWidth - collapsedTitleLeft - 122;
|
||||||
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
|
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
|
||||||
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
|
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
|
||||||
|
|
||||||
@@ -1304,6 +1307,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
),
|
),
|
||||||
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
||||||
maxLines: t > 0.5 ? 2 : 1,
|
maxLines: t > 0.5 ? 2 : 1,
|
||||||
|
softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1331,6 +1335,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
),
|
),
|
||||||
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
|
softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
'Swipe to switch device',
|
'Swipe to switch device',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: textColor.withOpacity(0.6),
|
color: textColor.withOpacity(0.6),
|
||||||
fontSize: 12,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -147,7 +147,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
'Swipe to switch device',
|
'Swipe to switch device',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: textColor.withOpacity(0.6),
|
color: textColor.withOpacity(0.6),
|
||||||
fontSize: 12,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.2.1-beta+21
|
version: 2.3.0-beta+22
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user