Compare commits

...
Author SHA1 Message Date
Your NameandClaude Opus 4.5 73ca6f230e chore: bump version to 2.3.0-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 23:19:31 +00:00
Your NameandClaude Opus 4.5 d0ab9ff618 feat(sendspin): add proxy authentication support for MA 2.7.1+
- Add auth token field and setAuthToken() method to SendspinService
- Send auth message before client/hello when connecting via HTTPS proxy
- Wait for auth_ok response before proceeding with handshake
- Provider now sets auth token from SettingsService before connecting
- Allows Sendspin to work through reverse proxies without exposing port 8927

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 23:02:31 +00:00
Your NameandClaude Opus 4.5 0989b322a2 fix(player): preload all tracks on app resume + preserve cached images
- Preload all player tracks in checkAndReconnect() after app resumes
- Preserve cached tracks that have images even if new event lacks image
- This ensures album art is available when swiping between players

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 22:06:09 +00:00
Your NameandClaude Opus 4.5 433eacfc43 fix(player): correct text width and artist parsing
Text overlap fix:
- Use collapsedWidth (with margins) instead of screenSize.width
- collapsedWidth is 24px narrower, fixing the overlap

Stale track data fix:
- Parse artist from title when title contains " - " and artist is unknown
- Don't overwrite cache if existing track has proper artist info
- Only set _currentTrack from event if not already set

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 22:02:41 +00:00
Your NameandClaude Opus 4.5 e24df29239 fix(player): proper text truncation in mini player
- Fix width calculation to use titleLeft position for accurate constraint
- Add softWrap: false in collapsed state to ensure ellipsis truncation
- Controls: 36 (prev) + 34 (play) + 36 (next) + 8 (margin) = 114px
- Reserve 122px from right edge for 8px gap to controls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 21:38:42 +00:00
Your NameandClaude Opus 4.5 2736b4478a fix(player): await fresh track data on player select
When resuming app after background, cached track data from player_updated
events may have incomplete artist info (e.g., "Artist - Title" in name field
with null artist). Now awaits _updatePlayerState() before notifying UI to
ensure fresh queue data is displayed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:59:11 +00:00
Your NameandClaude Opus 4.5 020a440f11 fix(player): consistent 14px secondary text and 8px control gap
- Set secondary text to 14px in both mini player and device selector bar
- Adjust text width for consistent 8px gap before player controls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:45:55 +00:00
Your NameandClaude Opus 4.5 2e7682facd fix(player): match mini player artist font size to device selector bar
Change artist font size from 14px to 12px in collapsed mini player to match
the DeviceSelectorBar styling for visual consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 20:08:06 +00:00
5 changed files with 110 additions and 14 deletions
+50 -6
View File
@@ -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();
@@ -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(
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();
}
@@ -1749,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();
}
+50 -3
View File
@@ -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':
+7 -2
View File
@@ -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,
),
),
+2 -2
View File
@@ -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
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.2.1-beta+21
version: 2.3.0-beta+22
environment:
sdk: '>=3.0.0 <4.0.0'