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 {
|
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();
|
||||||
@@ -1044,6 +1053,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (currentMedia != null) {
|
if (currentMedia != null) {
|
||||||
final mediaType = currentMedia['media_type'] as String?;
|
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') {
|
if (mediaType != 'flow_stream') {
|
||||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||||
final albumName = currentMedia['album'] as String?;
|
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(
|
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();
|
||||||
}
|
}
|
||||||
@@ -1742,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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,43 +186,46 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Stack(
|
||||||
children: [
|
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)
|
if (!isConnected && syncService.hasCache)
|
||||||
Container(
|
Positioned(
|
||||||
width: double.infinity,
|
top: 0,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
left: 0,
|
||||||
color: colorScheme.primaryContainer.withOpacity(0.5),
|
right: 0,
|
||||||
child: Row(
|
child: Container(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
children: [
|
color: colorScheme.primaryContainer.withOpacity(0.9),
|
||||||
SizedBox(
|
child: Row(
|
||||||
width: 12,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
height: 12,
|
children: [
|
||||||
child: CircularProgressIndicator(
|
SizedBox(
|
||||||
strokeWidth: 2,
|
width: 12,
|
||||||
color: colorScheme.primary,
|
height: 12,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
Text(
|
||||||
Text(
|
'Connecting...',
|
||||||
'Connecting...',
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
color: colorScheme.onPrimaryContainer,
|
||||||
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 = [];
|
final List<Uint8List> _audioBuffer = [];
|
||||||
bool _isFeeding = false;
|
bool _isFeeding = false;
|
||||||
bool _isStarted = false;
|
bool _isStarted = false;
|
||||||
|
bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors
|
||||||
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
|
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
|
||||||
|
|
||||||
// Error callback for operation failures
|
// Error callback for operation failures
|
||||||
@@ -202,11 +203,32 @@ class PcmAudioPlayer {
|
|||||||
|
|
||||||
/// Handle incoming audio data from the stream
|
/// Handle incoming audio data from the stream
|
||||||
void _onAudioData(Uint8List audioData) {
|
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;
|
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) {
|
if (_shouldBlockFeeding) {
|
||||||
// Silently ignore audio data when paused or transitioning
|
// If we're paused and audio is arriving, this is likely a new stream
|
||||||
// This prevents buffer accumulation during pause and any race conditions
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,15 +240,44 @@ class PcmAudioPlayer {
|
|||||||
_startPlayback();
|
_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
|
// Feed data if not currently feeding
|
||||||
if (!_isFeeding && _isStarted) {
|
if (!_isFeeding && _isStarted) {
|
||||||
_feedNextChunk();
|
_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
|
/// Start audio playback
|
||||||
Future<void> _startPlayback() async {
|
Future<void> _startPlayback() async {
|
||||||
if (_isStarted) return;
|
if (_isStarted) return;
|
||||||
@@ -298,7 +349,41 @@ class PcmAudioPlayer {
|
|||||||
|
|
||||||
// Check state again before the async feed call
|
// Check state again before the async feed call
|
||||||
if (samples.isNotEmpty && !_shouldBlockFeeding) {
|
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
|
// Don't update stats if we got paused during the feed
|
||||||
if (!_shouldBlockFeeding) {
|
if (!_shouldBlockFeeding) {
|
||||||
|
|||||||
@@ -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.0-beta+19
|
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