Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73ca6f230e | ||
|
|
d0ab9ff618 | ||
|
|
0989b322a2 | ||
|
|
433eacfc43 | ||
|
|
e24df29239 | ||
|
|
2736b4478a | ||
|
|
020a440f11 | ||
|
|
2e7682facd | ||
|
|
f5221b3160 | ||
|
|
4265b8a544 | ||
|
|
014f0a4903 | ||
|
|
2f0fc03dc9 | ||
|
|
9ae8462c2e |
@@ -416,6 +416,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
try {
|
||||
await refreshPlayers();
|
||||
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');
|
||||
} catch (e) {
|
||||
_logger.log('🔄 Connection verification failed, reconnecting: $e');
|
||||
@@ -581,6 +583,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_sendspinService?.dispose();
|
||||
_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
|
||||
_pcmAudioPlayer?.dispose();
|
||||
_pcmAudioPlayer = PcmAudioPlayer();
|
||||
@@ -1044,6 +1053,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
if (currentMedia != null) {
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
|
||||
// Clear audiobook context when switching to music (track) playback
|
||||
if (mediaType == 'track' && _currentAudiobook != null) {
|
||||
_logger.log('📚 Media type changed to track - clearing audiobook context');
|
||||
clearCurrentAudiobook();
|
||||
}
|
||||
|
||||
if (mediaType != 'flow_stream') {
|
||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||
final albumName = currentMedia['album'] as String?;
|
||||
@@ -1075,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(
|
||||
itemId: currentMedia['queue_item_id'] as String? ?? '',
|
||||
provider: 'library',
|
||||
name: currentMedia['title'] as String? ?? 'Unknown Track',
|
||||
name: trackTitle,
|
||||
uri: currentMedia['uri'] as String?,
|
||||
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,
|
||||
metadata: metadata,
|
||||
);
|
||||
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
||||
|
||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
||||
_currentTrack = trackFromEvent;
|
||||
// Only cache if we don't already have better data from queue
|
||||
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();
|
||||
}
|
||||
@@ -1742,6 +1788,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
_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) {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -186,43 +186,46 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
return Stack(
|
||||
children: [
|
||||
// Connecting banner when showing cached data
|
||||
// Main content
|
||||
RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
child: _buildConnectedView(context, maProvider),
|
||||
),
|
||||
// Connecting banner overlay (doesn't affect layout)
|
||||
if (!isConnected && syncService.hasCache)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: colorScheme.primaryContainer.withOpacity(0.5),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
color: colorScheme.primaryContainer.withOpacity(0.9),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Connecting...',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Connecting...',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
child: _buildConnectedView(context, maProvider),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -68,6 +68,7 @@ class PcmAudioPlayer {
|
||||
final List<Uint8List> _audioBuffer = [];
|
||||
bool _isFeeding = false;
|
||||
bool _isStarted = false;
|
||||
bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors
|
||||
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
|
||||
|
||||
// Error callback for operation failures
|
||||
@@ -202,11 +203,32 @@ class PcmAudioPlayer {
|
||||
|
||||
/// Handle incoming audio data from the stream
|
||||
void _onAudioData(Uint8List audioData) {
|
||||
// Ignore data if in error state or if in transitional/paused state
|
||||
// Ignore data if in error state
|
||||
if (_state == PcmPlayerState.error) return;
|
||||
|
||||
// Handle audio arriving during paused/transitional states
|
||||
// This can happen when audio frames arrive before stream/start message (race condition)
|
||||
// We need to auto-recover in this case
|
||||
if (_shouldBlockFeeding) {
|
||||
// Silently ignore audio data when paused or transitioning
|
||||
// This prevents buffer accumulation during pause and any race conditions
|
||||
// If we're paused and audio is arriving, this is likely a new stream
|
||||
// starting before the stream/start message. Queue for auto-recovery.
|
||||
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
|
||||
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
|
||||
_isAutoRecovering = true;
|
||||
_audioBuffer.add(audioData);
|
||||
|
||||
// Trigger async recovery
|
||||
_autoRecoverFromPause();
|
||||
return;
|
||||
}
|
||||
|
||||
// If already recovering or in transitional state, buffer the data
|
||||
if (_isAutoRecovering) {
|
||||
_audioBuffer.add(audioData);
|
||||
return;
|
||||
}
|
||||
|
||||
// For other blocking states (pausing, stopping, resuming), ignore
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -218,15 +240,44 @@ class PcmAudioPlayer {
|
||||
_startPlayback();
|
||||
}
|
||||
|
||||
// NOTE: Auto-resume from paused state has been removed to prevent race conditions.
|
||||
// The provider should explicitly call play() when ready to resume.
|
||||
|
||||
// Feed data if not currently feeding
|
||||
if (!_isFeeding && _isStarted) {
|
||||
_feedNextChunk();
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-recover from paused state when audio arrives unexpectedly
|
||||
Future<void> _autoRecoverFromPause() async {
|
||||
_logger.log('PcmAudioPlayer: Auto-recovering from pause');
|
||||
|
||||
try {
|
||||
// Re-initialize the native player
|
||||
await pcm.FlutterPcmSound.setup(
|
||||
sampleRate: _format.sampleRate,
|
||||
channelCount: _format.channels,
|
||||
);
|
||||
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
|
||||
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
|
||||
await pcm.FlutterPcmSound.start();
|
||||
|
||||
_isStarted = true;
|
||||
_state = PcmPlayerState.playing;
|
||||
_startElapsedTimeTimer();
|
||||
_logger.log('PcmAudioPlayer: Auto-recovery from pause successful');
|
||||
|
||||
// Start feeding buffered data
|
||||
_isAutoRecovering = false;
|
||||
if (_audioBuffer.isNotEmpty && !_isFeeding) {
|
||||
_feedNextChunk();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('PcmAudioPlayer: Auto-recovery from pause failed: $e');
|
||||
_isAutoRecovering = false;
|
||||
_state = PcmPlayerState.error;
|
||||
_emitError(PcmPlayerError.resumeFailed, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Start audio playback
|
||||
Future<void> _startPlayback() async {
|
||||
if (_isStarted) return;
|
||||
@@ -298,7 +349,41 @@ class PcmAudioPlayer {
|
||||
|
||||
// Check state again before the async feed call
|
||||
if (samples.isNotEmpty && !_shouldBlockFeeding) {
|
||||
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
|
||||
try {
|
||||
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
|
||||
} catch (feedError) {
|
||||
// Auto-recover from "must call setup first" error
|
||||
// This happens when audio frames arrive before stream/start message
|
||||
if (feedError.toString().contains('must call setup first') && !_isAutoRecovering) {
|
||||
_logger.log('PcmAudioPlayer: Auto-recovering from setup error');
|
||||
_isAutoRecovering = true;
|
||||
|
||||
try {
|
||||
// Re-initialize the native player
|
||||
await pcm.FlutterPcmSound.setup(
|
||||
sampleRate: _format.sampleRate,
|
||||
channelCount: _format.channels,
|
||||
);
|
||||
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
|
||||
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
|
||||
await pcm.FlutterPcmSound.start();
|
||||
_isStarted = true;
|
||||
_state = PcmPlayerState.playing;
|
||||
_startElapsedTimeTimer();
|
||||
_logger.log('PcmAudioPlayer: Auto-recovery successful');
|
||||
|
||||
// Retry the feed with the current chunk
|
||||
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
|
||||
} catch (recoveryError) {
|
||||
_logger.log('PcmAudioPlayer: Auto-recovery failed: $recoveryError');
|
||||
_isAutoRecovering = false;
|
||||
rethrow;
|
||||
}
|
||||
_isAutoRecovering = false;
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't update stats if we got paused during the feed
|
||||
if (!_shouldBlockFeeding) {
|
||||
|
||||
@@ -84,8 +84,16 @@ class SendspinService {
|
||||
|
||||
bool _isDisposed = false;
|
||||
|
||||
// Auth token for proxy authentication (MA 2.7.1+)
|
||||
String? _authToken;
|
||||
|
||||
SendspinService(this.serverUrl);
|
||||
|
||||
/// Set the MA auth token for proxy authentication
|
||||
void setAuthToken(String? token) {
|
||||
_authToken = token;
|
||||
}
|
||||
|
||||
/// Initialize and connect to Sendspin server
|
||||
/// Uses persistent player ID and username-based naming
|
||||
Future<bool> connect() async {
|
||||
@@ -105,10 +113,11 @@ class SendspinService {
|
||||
bool connected = false;
|
||||
|
||||
// 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()) {
|
||||
final externalUrl = _buildExternalSendspinUrl();
|
||||
_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
|
||||
@@ -158,10 +167,11 @@ class SendspinService {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// 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
|
||||
final webSocket = await WebSocket.connect(url).timeout(timeout);
|
||||
@@ -176,6 +186,29 @@ class SendspinService {
|
||||
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
|
||||
// This is required by the Sendspin protocol - server waits for this
|
||||
// Message format follows aiosendspin protocol spec
|
||||
@@ -228,12 +261,18 @@ class SendspinService {
|
||||
|
||||
/// Wait for server acknowledgment after connection
|
||||
Completer<bool>? _ackCompleter;
|
||||
Completer<bool>? _authCompleter;
|
||||
|
||||
Future<bool> _waitForAck() async {
|
||||
_ackCompleter = Completer<bool>();
|
||||
return _ackCompleter!.future;
|
||||
}
|
||||
|
||||
Future<bool> _waitForAuth() async {
|
||||
_authCompleter = Completer<bool>();
|
||||
return _authCompleter!.future;
|
||||
}
|
||||
|
||||
/// Handle incoming WebSocket messages
|
||||
/// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data)
|
||||
void _handleMessage(dynamic message) {
|
||||
@@ -310,6 +349,14 @@ class SendspinService {
|
||||
onStreamEnd?.call();
|
||||
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 'connected':
|
||||
case 'registered':
|
||||
|
||||
@@ -1001,7 +1001,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// Artist: medium weight, secondary (18px expanded, was 16px)
|
||||
// Album: light, tertiary (15px expanded, was 13px)
|
||||
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 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 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 titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
|
||||
|
||||
@@ -1304,6 +1307,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
||||
maxLines: t > 0.5 ? 2 : 1,
|
||||
softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -1331,6 +1335,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
||||
maxLines: 1,
|
||||
softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -87,7 +87,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
'Swipe to switch device',
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -147,7 +147,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
'Swipe to switch device',
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.2.0-beta+19
|
||||
version: 2.3.0-beta+22
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Reference in New Issue
Block a user