Compare commits

..
Author SHA1 Message Date
Your NameandClaude Opus 4.5 d7bfb66fab fix: Reduce play button nudge to add space from skip
Play: +3px, Skip: +6px (more space between play and skip)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:56:19 +00:00
Your NameandClaude Opus 4.5 222a66db61 fix: Nudge play and skip buttons toward power
Move play and skip 6px right instead of moving power left

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:38:55 +00:00
Your NameandClaude Opus 4.5 8eec9599f3 fix: Revert to Material Icons, use Transform to close gap
- Reverted MDI icons back to Material Icons
- Used Transform.translate(-6, 0) to nudge power button left
- This closes the gap between skip and power without changing icon pack

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:37:41 +00:00
Your NameandClaude Opus 4.5 fe45a34c35 feat: Switch to MDI icons for tighter spacing
Replace Material Icons with Material Design Icons (MDI) in:
- Device list: play/pause, skip next, power
- Mini player: skip previous, play/pause, skip next
- Expanded player: shuffle, repeat, skip controls, audiobook controls

MDI icons have less built-in whitespace for more compact layouts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:21:54 +00:00
Your NameandClaude Opus 4.5 232862b164 fix: Remove gap between skip and power buttons
Set padding to zero on both buttons for minimal spacing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:07:37 +00:00
Your NameandClaude Opus 4.5 dc6004ac00 feat: Asymmetric device list animation timing
- Expand: 200ms (slower, more deliberate reveal)
- Collapse: 150ms (snappier dismiss)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:00:34 +00:00
Your NameandClaude Opus 4.5 62fc5ab2f0 fix: Reduce gap between skip and power buttons
Reduced power button horizontal padding from 4 to 2

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:51:39 +00:00
Your NameandClaude Opus 4.5 807bebbab9 fix: Restore skip button to original size
Keep skip next at 28, only reduce play (28) and power (20)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:05:29 +00:00
Your NameandClaude Opus 4.5 7edf7bbfc8 fix: Reduce device selector button sizes for better alignment
- Play/Pause: 34→28 icon size, smaller container
- Skip next: 28→24 icon, reduced horizontal padding
- Power button: 28→20 (smallest of the three)
- Reduced gaps between buttons for more compact layout

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:03:26 +00:00
Your NameandClaude Opus 4.5 c4ad0a6279 fix: Match play button sizing exactly to mini player
Use SizedBox wrapper with zero padding for play button, matching
the mini player's Container approach. Also restore right margin to 4px.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:39:12 +00:00
Your NameandClaude Opus 4.5 6ebf2ed15b feat: Update device list buttons to play/pause, next, power
- Change button order from prev/play/next to play/pause/next/power
- Match mini player button sizes (play=34, skip/power=28)
- Power button always visible when player available (dimmed when off)
- Play/pause and next only shown when powered with content

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:23:16 +00:00
Your NameandClaude Opus 4.5 91b713aab8 fix: Back gesture priority for device list over navigation
Add device list (player reveal) to didPopRoute() priority chain.
Previously back gesture would pop navigation routes before dismissing
the device list when on screens other than home.

Priority order is now: device list > expanded player > navigation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:55:26 +00:00
Your NameandClaude Opus 4.5 8ce567a1cf perf: Revert TweenSequence to simple CurvedAnimation for smoother FPS
TweenSequence with chained curves was causing low FPS on the mini player
to expanded player animation. Reverted to simple CurvedAnimation which
is more performant:

- Replace 3-phase TweenSequence with single CurvedAnimation (easeOutCubic)
- Reduce expand duration from 350ms to 280ms
- Simplify bounce animation back to sine-wave calculation
- Keep gesture-driven expand/collapse functionality

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:41:23 +00:00
Your NameandClaude Opus 4.5 2081cf8552 fix: Device list swipe-down and back gesture handling
- Fix swipe-down gesture on active mini player to reveal device list
  (was blocked by gesture tracking starting immediately)
- Fix back gesture priority: device list now closes before underlying
  album/artist screens by handling PopScope at GlobalPlayerOverlay level

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 10:37:10 +00:00
Your NameandClaude Opus 4.5 8dae062663 fix: Correct swipe direction and faster collapse
- Fix inverted drag: swipe down now moves cards down (toward dismiss)
- Faster collapse animations:
  - Mini player: 250ms → 200ms
  - Player list: 200ms → 150ms

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:33:32 +00:00
Your NameandClaude Opus 4.5 59eb80c11b fix: Animation refinements based on testing feedback
- Faster collapse (250ms vs 350ms expand) for snappier feel
- Simplified bounce to single clean motion (down 10px → back)
  - Removed overshoot phases that looked like multiple bounces
  - Duration reduced to 200ms
- Fixed swipe dismiss on player list vanishing halfway
  - Reset dragOffset before dismiss() so animation starts correctly

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:13:55 +00:00
Your NameandClaude Opus 4.5 d118dd8fe1 feat: Gesture-driven expand/collapse with keyframe animations
- Replace threshold-based expand/collapse with finger-tracking
  - Mini player now follows finger during vertical swipe
  - Snap decision based on velocity (800px/s) and position (30%)

- Add TweenSequence for phased animations
  - Phase 1 (0-40%): Quick acceleration with easeOut
  - Phase 2 (40-75%): Consistent cruise with linear
  - Phase 3 (75-100%): Settle with slight overshoot (easeOutBack)

- Fix position calculation discontinuity
  - Removed abrupt switch at t=0.1
  - Now smoothly fades out slide/bounce offsets over first 15%

- Add settle bounce when cards collapse into mini player
  - TweenSequence bounce: push down 12px → overshoot -4px → settle
  - Triggered when cards are 70% collapsed for natural "landing" feel

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 08:53:38 +00:00
Your Name 64b36d37ad Revert test: restore normal mini player colors 2025-12-23 07:32:09 +00:00
Your Name 339e55df93 TEST: Bright red mini player background to verify build freshness 2025-12-23 07:13:48 +00:00
Your Name 86f3798637 Add unified device UI with swipe-down reveal animation
Cherry-picked commits 39f43cc through d0ffd4a from feature/unified-device-animation
to get clean build state before Gradle issues.
2025-12-23 06:57:44 +00:00
Your NameandClaude Opus 4.5 d5c96880d7 chore: Bump version to 2.3.3-beta+25
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 10:10:43 +00:00
Your NameandClaude Opus 4.5 540b95f558 Add MA version detection for Sendspin proxy support
- Add _parseServerVersion() helper for version comparisons
- Add _serverHasSendspinProxy() to detect MA 2.8.0+ (has /sendspin proxy)
- Add _logSendspinTroubleshooting() with version-specific guidance
- Update connection logic to try local proxy fallback for local IPs
- Improve logging to show server version and proxy availability
- MA 2.7.x users now get clear guidance: upgrade to 2.8.0+ or configure proxy

Fixes #17

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 21:50:54 +00:00
Your Name a1c84f29c1 fix: Fix followRedirects usage and add missing painting import 2025-12-21 21:00:02 +00:00
Your Name 2cf3135f18 fix(ci): Add flutter gen-l10n step to build workflow 2025-12-21 20:46:21 +00:00
Your NameandClaude Opus 4.5 1b33c57cb7 fix: Improve local IP and Sendspin connection handling
Fixes issue #17 - users unable to connect via local IP or see local player

Changes:
1. **Local IP connection handling**:
   - Don't follow HTTP->HTTPS redirects for local IPs
   - Detect 308/301/302 redirects and handle gracefully
   - Prevents certificate verification errors when server redirects to HTTPS

2. **Sendspin connection logic**:
   - Remove non-existent API calls (sendspin/connection_info, webrtc_offer, etc.)
   - For local IPs: Connect directly to ws://ip:8927/sendspin
   - For external/HTTPS: Use proxy at wss://server/sendspin with auth
   - Better error messages explaining what to configure

3. **Utility improvements**:
   - Add _isLocalNetworkHost() helper for IP detection
   - Include .ts.net (Tailscale) as local network

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 20:35:52 +00:00
15 changed files with 1192 additions and 214 deletions
+3
View File
@@ -41,6 +41,9 @@ jobs:
- name: Generate code (Drift database)
run: dart run build_runner build --delete-conflicting-outputs
- name: Generate localizations
run: flutter gen-l10n
- name: Generate launcher icons
run: flutter pub run flutter_launcher_icons
+10 -1
View File
@@ -172,11 +172,20 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
@override
Future<bool> didPopRoute() async {
// Intercept back button at app level - runs BEFORE Navigator processes it
// If player is expanded, collapse it and consume the back gesture
// Priority order: device list > expanded player > normal navigation
// Device list has highest priority - dismiss it first
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
GlobalPlayerOverlay.dismissPlayerReveal();
return true; // We handled it, don't let Navigator process it
}
// Then check expanded player
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
return true; // We handled it, don't let Navigator process it
}
// Let Navigator handle the back gesture normally
return super.didPopRoute();
}
+211 -44
View File
@@ -529,24 +529,35 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Check if server version is >= 2.7.0b20 (uses Sendspin instead of builtin_player)
bool _serverUsesSendspin() {
/// Parse server version into components for version comparisons
/// Returns null if version cannot be parsed
({int major, int minor, int patch, int? beta})? _parseServerVersion() {
final serverInfo = _api?.serverInfo;
if (serverInfo == null) return false;
if (serverInfo == null) return null;
final versionStr = serverInfo['server_version'] as String?;
if (versionStr == null) return false;
if (versionStr == null) return null;
// Parse version like "2.8.0b2" or "2.7.0b20" or "2.7.1"
// Format: MAJOR.MINOR.PATCH[bBETA]
final versionRegex = RegExp(r'^(\d+)\.(\d+)\.(\d+)(?:b(\d+))?');
final match = versionRegex.firstMatch(versionStr);
if (match == null) return false;
if (match == null) return null;
final major = int.parse(match.group(1)!);
final minor = int.parse(match.group(2)!);
final patch = int.parse(match.group(3)!);
final beta = match.group(4) != null ? int.parse(match.group(4)!) : null;
return (
major: int.parse(match.group(1)!),
minor: int.parse(match.group(2)!),
patch: int.parse(match.group(3)!),
beta: match.group(4) != null ? int.parse(match.group(4)!) : null,
);
}
/// Check if server version is >= 2.7.0b20 (uses Sendspin instead of builtin_player)
bool _serverUsesSendspin() {
final version = _parseServerVersion();
if (version == null) return false;
final (:major, :minor, :patch, :beta) = version;
// Compare with 2.7.0b20
if (major > 2) return true;
@@ -562,6 +573,32 @@ class MusicAssistantProvider with ChangeNotifier {
return beta >= 20;
}
/// Check if server version is >= 2.8.0 (has built-in /sendspin proxy endpoint)
/// The /sendspin proxy was added in MA 2.8.0 (PR #2840)
/// MA 2.7.x does NOT have this proxy - users must expose port 8927 directly
/// or manually configure reverse proxy routing to port 8927
bool _serverHasSendspinProxy() {
final version = _parseServerVersion();
if (version == null) return false;
final (:major, :minor, :patch, :beta) = version;
// Compare with 2.8.0
if (major > 2) return true;
if (major < 2) return false;
// major == 2
if (minor > 8) return true;
if (minor < 8) return false;
// minor == 8, any 2.8.x version has the proxy
return true;
}
/// Get the server version string for logging
String _getServerVersionString() {
final serverInfo = _api?.serverInfo;
return serverInfo?['server_version'] as String? ?? 'unknown';
}
Future<void> _registerLocalPlayer() async {
if (_api == null) return;
@@ -665,19 +702,24 @@ class MusicAssistantProvider with ChangeNotifier {
/// Connect to Music Assistant via Sendspin protocol (MA 2.7.0b20+)
/// This is the replacement for builtin_player when that API is not available.
///
/// Connection strategy:
/// 1. If server is HTTPS, try external wss://{server}/sendspin first
/// 2. Get local_ws_url from API and try that
/// 3. WebRTC fallback as last resort (requires TURN servers)
/// Connection strategy depends on MA version and network:
/// - MA 2.8.0+: Has built-in /sendspin proxy, works with any reverse proxy setup
/// - MA 2.7.x: NO proxy, must either:
/// - Use local IP with port 8927 exposed, OR
/// - Manually configure reverse proxy to route /sendspin to port 8927
Future<bool> _connectViaSendspin() async {
if (_api == null || _serverUrl == null) return false;
try {
final serverVersion = _getServerVersionString();
final hasProxy = _serverHasSendspinProxy();
_logger.log('Sendspin: Server version $serverVersion, has proxy: $hasProxy');
// Initialize Sendspin service
_sendspinService?.dispose();
_sendspinService = SendspinService(_serverUrl!);
// Set auth token for MA 2.7.1+ proxy authentication
// Set auth token for proxy authentication (MA 2.8.0+ or manually configured proxy)
final authToken = await SettingsService.getMaAuthToken();
if (authToken != null) {
_sendspinService!.setAuthToken(authToken);
@@ -706,42 +748,59 @@ class MusicAssistantProvider with ChangeNotifier {
_sendspinService!.onStreamEnd = _handleSendspinStreamEnd;
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_logger.log('Sendspin: Player ID: $playerId');
// Strategy 1: If server is HTTPS, try external wss:// first
final isHttps = _serverUrl!.startsWith('https://') ||
(!_serverUrl!.contains('://') && !_serverUrl!.contains(':'));
// Parse server URL to determine connection strategy
final serverUri = Uri.parse(_serverUrl!.startsWith('http')
? _serverUrl!
: 'https://$_serverUrl');
final isLocalIp = _isLocalNetworkHost(serverUri.host);
final isHttps = serverUri.scheme == 'https' ||
(!_serverUrl!.contains('://') && !isLocalIp);
// Strategy 1: For local IPs, connect directly to Sendspin port 8927
if (isLocalIp) {
final localSendspinUrl = 'ws://${serverUri.host}:8927/sendspin';
_logger.log('Sendspin: Local network detected, trying direct connection: $localSendspinUrl');
final connected = await _sendspinService!.connectWithUrl(localSendspinUrl);
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local network (port 8927)');
return true;
}
_logger.log('⚠️ Sendspin: Local connection to port 8927 failed');
// For local IPs, also try the proxy path in case user has a local reverse proxy
_logger.log('Sendspin: Trying local proxy fallback...');
final localProxyUrl = 'ws://${serverUri.host}:${serverUri.hasPort ? serverUri.port : 8095}/sendspin';
final proxyConnected = await _sendspinService!.connectWithUrl(localProxyUrl);
if (proxyConnected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local proxy');
return true;
}
}
// Strategy 2: For external/HTTPS servers, use the proxy at /sendspin
if (isHttps || !isLocalIp) {
if (hasProxy) {
_logger.log('Sendspin: MA 2.8.0+ detected, using built-in /sendspin proxy');
} else {
_logger.log('Sendspin: MA 2.7.x detected, trying /sendspin (requires manual proxy config)');
}
if (isHttps) {
_logger.log('Sendspin: Server is HTTPS, trying external connection first');
final connected = await _sendspinService!.connect();
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via external URL');
_logger.log('✅ Sendspin: Connected via external proxy');
return true;
}
_logger.log('⚠️ Sendspin: External connection failed, trying local URL');
_logger.log('⚠️ Sendspin: External proxy connection failed');
}
// Strategy 2: Get local_ws_url from API and try that
final connectionInfo = await _api!.getSendspinConnectionInfo(playerId);
if (connectionInfo != null) {
final localWsUrl = connectionInfo['local_ws_url'] as String?;
if (localWsUrl != null) {
_logger.log('Sendspin: Trying local WebSocket URL: $localWsUrl');
final connected = await _sendspinService!.connectWithUrl(localWsUrl);
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local URL');
return true;
}
}
}
// Strategy 3: WebRTC fallback (requires TURN servers from Nabucasa)
// For now, just log that we'd need WebRTC - implementing full WebRTC
// would require additional dependencies (flutter_webrtc)
_logger.log('⚠️ Sendspin: WebRTC fallback not yet implemented');
_logger.log('ℹ️ For external access, configure /sendspin route in your reverse proxy');
// All strategies failed - provide version-specific guidance
_logger.log('❌ Sendspin: All connection strategies failed');
_logSendspinTroubleshooting(isLocalIp, hasProxy, serverVersion);
return false;
} catch (e) {
@@ -750,6 +809,29 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Log troubleshooting guidance based on setup
void _logSendspinTroubleshooting(bool isLocalIp, bool hasProxy, String serverVersion) {
if (isLocalIp) {
_logger.log('ℹ️ LOCAL IP SETUP: Add port 8927 to your Docker compose:');
_logger.log(' ports:');
_logger.log(' - "8095:8095"');
_logger.log(' - "8927:8927" # Required for Sendspin');
} else if (!hasProxy) {
// MA 2.7.x without built-in proxy
_logger.log('ℹ️ MA $serverVersion does not have built-in /sendspin proxy');
_logger.log('ℹ️ OPTIONS:');
_logger.log(' 1. Upgrade to Music Assistant 2.8.0+ (recommended)');
_logger.log(' 2. Or add reverse proxy config for /sendspin → port 8927');
_logger.log(' Traefik: PathPrefix(`/sendspin`) → service port 8927');
_logger.log(' Nginx: location /sendspin { proxy_pass http://ma:8927; }');
} else {
// MA 2.8.0+ but still failing
_logger.log('ℹ️ MA $serverVersion should have /sendspin proxy');
_logger.log('ℹ️ Check that your reverse proxy forwards WebSocket connections');
_logger.log(' Ensure Upgrade and Connection headers are passed through');
}
}
/// Handle Sendspin play command
void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) async {
_logger.log('🎵 Sendspin: Play command received');
@@ -2157,6 +2239,26 @@ class MusicAssistantProvider with ChangeNotifier {
await Future.wait(
players.map((player) => _preloadPlayerTrack(player)),
);
// After preloading, update _currentTrack from cache if it has better data
// This fixes the issue where mini player shows wrong info but device list is correct
if (_selectedPlayer != null && _currentTrack != null) {
final cachedTrack = _cacheService.getCachedTrackForPlayer(_selectedPlayer!.playerId);
if (cachedTrack != null && cachedTrack.uri == _currentTrack!.uri) {
final cachedHasImage = cachedTrack.metadata?['images'] != null;
final currentHasImage = _currentTrack!.metadata?['images'] != null;
final cachedHasArtist = cachedTrack.artistsString.isNotEmpty &&
cachedTrack.artistsString != 'Unknown Artist';
final currentHasArtist = _currentTrack!.artistsString.isNotEmpty &&
_currentTrack!.artistsString != 'Unknown Artist';
if ((cachedHasImage && !currentHasImage) || (cachedHasArtist && !currentHasArtist)) {
_currentTrack = cachedTrack;
_logger.log('🎵 Updated currentTrack from cache with better metadata');
notifyListeners();
}
}
}
}
void _startPlayerStatePolling() {
@@ -2231,12 +2333,56 @@ class MusicAssistantProvider with ChangeNotifier {
final queue = await getQueue(_selectedPlayer!.playerId);
if (queue != null && queue.currentItem != null) {
final queueTrack = queue.currentItem!.track;
final trackChanged = _currentTrack == null ||
_currentTrack!.uri != queue.currentItem!.track.uri ||
_currentTrack!.name != queue.currentItem!.track.name;
_currentTrack!.uri != queueTrack.uri ||
_currentTrack!.name != queueTrack.name;
if (trackChanged) {
_currentTrack = queue.currentItem!.track;
// Check if cached track has better metadata (images, proper artist info)
// This prevents losing album art and artist data when resuming the app
final cachedTrack = _cacheService.getCachedTrackForPlayer(_selectedPlayer!.playerId);
final cachedHasImage = cachedTrack?.metadata?['images'] != null;
final queueHasImage = queueTrack.metadata?['images'] != null;
// Also check for malformed artist names (e.g., "Artist - Title" format in name)
final cachedHasProperArtist = cachedTrack?.artistsString.isNotEmpty == true &&
cachedTrack?.artistsString != 'Unknown Artist' &&
!cachedTrack!.name.contains(' - '); // Proper track doesn't have artist in name
final queueHasProperArtist = queueTrack.artistsString.isNotEmpty &&
queueTrack.artistsString != 'Unknown Artist' &&
!queueTrack.name.contains(' - ');
// Also check if _currentTrack (set from cache in selectPlayer) has good data
final currentHasImage = _currentTrack?.metadata?['images'] != null;
final currentHasProperArtist = _currentTrack?.artistsString.isNotEmpty == true &&
_currentTrack?.artistsString != 'Unknown Artist' &&
!(_currentTrack?.name.contains(' - ') ?? false);
// Match by URI - name might differ if queue has malformed data
final isSameTrackAsCached = cachedTrack?.uri == queueTrack.uri;
final cachedHasBetterData = (cachedHasImage && !queueHasImage) ||
(cachedHasProperArtist && !queueHasProperArtist);
// Queue track has bad metadata if it lacks image AND lacks proper artist
final queueHasBadMetadata = !queueHasImage && !queueHasProperArtist;
// Current track (from cache at selectPlayer) has good metadata
final currentHasGoodMetadata = currentHasImage || currentHasProperArtist;
if (isSameTrackAsCached && cachedHasBetterData) {
// Use cached track which has better metadata
_currentTrack = cachedTrack;
_logger.log('🎵 Using cached track with better metadata for ${cachedTrack!.name}');
} else if (queueHasBadMetadata && currentHasGoodMetadata) {
// Queue has bad data, but _currentTrack (set from cache) is good - keep it
_logger.log('🎵 Keeping currentTrack - queue has bad metadata but current is good');
// Don't change _currentTrack, keep the good data
} else {
_currentTrack = queueTrack;
// Update cache if queue track has good metadata
if (queueHasImage || queueHasProperArtist) {
_cacheService.setCachedTrackForPlayer(_selectedPlayer!.playerId, queueTrack);
}
}
stateChanged = true;
// Clear audiobook context if switched to a different media item
@@ -2972,4 +3118,25 @@ class MusicAssistantProvider with ChangeNotifier {
_api?.dispose();
super.dispose();
}
// ============================================================================
// UTILITY METHODS
// ============================================================================
/// Check if a hostname is a local/private network address
bool _isLocalNetworkHost(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
host.endsWith('.ts.net'); // Tailscale - treat as local since it's a VPN
}
}
+1
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/music_assistant_api.dart';
+6
View File
@@ -62,6 +62,12 @@ class _HomeScreenState extends State<HomeScreen> {
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
// If player reveal (device list) is visible, dismiss it first (with animation)
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
GlobalPlayerOverlay.dismissPlayerReveal();
return;
}
// If global player is expanded, collapse it first
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
+97 -19
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import 'no_auth_strategy.dart';
@@ -86,6 +87,22 @@ class AuthManager {
return baseUrl;
}
/// Check if a hostname is a local/private IP address
bool _isLocalIpAddress(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local');
}
/// Try to detect auth strategy for a given URL
/// Returns the strategy if successful, null if connection failed
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
@@ -187,15 +204,50 @@ class AuthManager {
_logger.log('Checking MA API at: $apiUrl');
// Try to get server info - this tells us if auth is required
// Note: The command is just 'info', not 'server/info'
final response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
}),
).timeout(const Duration(seconds: 5));
// Use IOClient to control redirect behavior for local IPs
final isLocalIp = _isLocalIpAddress(uri.host);
http.Response response;
if (isLocalIp) {
// For local IPs, don't follow redirects - handle them explicitly
final client = HttpClient();
try {
final request = await client.postUrl(apiUrl);
request.followRedirects = false;
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'command': 'info'}));
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
// Check for redirect to HTTPS
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
final location = ioResponse.headers.value('location');
_logger.log('Local IP redirects to: $location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
// Don't follow the redirect - it will likely fail with cert error
// Instead, return null to trigger fallback logic
client.close();
return null;
}
}
final body = await ioResponse.transform(utf8.decoder).join();
response = http.Response(body, ioResponse.statusCode);
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.post which follows redirects
response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
}),
).timeout(const Duration(seconds: 5));
}
_logger.log('MA API response: ${response.statusCode}');
_logger.log('MA API body: ${response.body}');
@@ -255,21 +307,47 @@ class AuthManager {
/// Test if server is accessible without authentication
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
try {
// Test root endpoint - Music Assistant doesn't have /api/info
final testUrl = serverUrl;
_logger.log('No-auth test URL: $testUrl');
final uri = Uri.parse(serverUrl);
_logger.log('No-auth test URL: $serverUrl');
final response = await http.get(
Uri.parse(testUrl),
// Don't follow redirects - we want to see if server requires auth
).timeout(const Duration(seconds: 10));
final isLocalIp = _isLocalIpAddress(uri.host);
int statusCode;
_logger.log('No-auth status: ${response.statusCode}');
if (isLocalIp) {
// For local IPs, don't follow redirects to avoid HTTPS cert issues
final client = HttpClient();
try {
final request = await client.getUrl(uri);
request.followRedirects = false;
final response = await request.close().timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
// Check for redirect to HTTPS
if (statusCode == 308 || statusCode == 301 || statusCode == 302) {
final location = response.headers.value('location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Local IP redirects to HTTPS: $location');
client.close();
return false; // Can't connect - would need HTTPS which has cert issues
}
}
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.get
final response = await http.get(uri).timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
}
_logger.log('No-auth status: $statusCode');
// 200 = no auth required
// 302/307 = redirect (probably to auth)
// 302/307/308 = redirect (probably to auth or HTTPS)
// 401 = auth required
if (response.statusCode == 200) {
if (statusCode == 200) {
return true;
}
+9 -83
View File
@@ -2302,89 +2302,15 @@ class MusicAssistantAPI {
return _eventStreams['sendspin_player']!.stream;
}
/// Get Sendspin connection info for a player
/// Returns connection details including:
/// - local_ws_url: Direct WebSocket URL (e.g., ws://192.168.4.120:8927/sendspin)
/// - ice_servers: List of ICE/TURN servers for WebRTC fallback
/// - player_id: The player's ID
Future<Map<String, dynamic>?> getSendspinConnectionInfo(String playerId) async {
try {
_logger.log('Sendspin: Getting connection info for player $playerId');
final response = await _sendCommand(
'sendspin/connection_info',
args: {
'player_id': playerId,
},
);
final result = response['result'] as Map<String, dynamic>?;
if (result != null) {
_logger.log('Sendspin: Got connection info - local_ws_url: ${result['local_ws_url']}');
_logger.log('Sendspin: ICE servers count: ${(result['ice_servers'] as List?)?.length ?? 0}');
}
return result;
} catch (e) {
_logger.log('Sendspin: Error getting connection info: $e');
return null;
}
}
/// Send WebRTC offer to Sendspin server
/// Used when establishing WebRTC connection as fallback
Future<Map<String, dynamic>?> sendspinOffer(String playerId, String sdp) async {
try {
_logger.log('Sendspin: Sending WebRTC offer for player $playerId');
final response = await _sendCommand(
'sendspin/webrtc_offer',
args: {
'player_id': playerId,
'sdp': sdp,
},
);
return response['result'] as Map<String, dynamic>?;
} catch (e) {
_logger.log('Sendspin: Error sending WebRTC offer: $e');
return null;
}
}
/// Send WebRTC answer to Sendspin server
Future<void> sendspinAnswer(String playerId, String sdp) async {
try {
_logger.log('Sendspin: Sending WebRTC answer for player $playerId');
await _sendCommand(
'sendspin/webrtc_answer',
args: {
'player_id': playerId,
'sdp': sdp,
},
);
} catch (e) {
_logger.log('Sendspin: Error sending WebRTC answer: $e');
rethrow;
}
}
/// Send ICE candidate to Sendspin server
Future<void> sendspinIceCandidate(String playerId, Map<String, dynamic> candidate) async {
try {
await _sendCommand(
'sendspin/ice_candidate',
args: {
'player_id': playerId,
'candidate': candidate,
},
);
} catch (e) {
_logger.log('Sendspin: Error sending ICE candidate: $e');
// Don't rethrow - ICE candidate failures are common and non-fatal
}
}
// NOTE: The following Sendspin API methods were removed because they don't exist in MA:
// - getSendspinConnectionInfo (sendspin/connection_info)
// - sendspinOffer (sendspin/webrtc_offer)
// - sendspinAnswer (sendspin/webrtc_answer)
// - sendspinIceCandidate (sendspin/ice_candidate)
//
// Sendspin connection is handled directly via WebSocket to:
// - Local: ws://{server-ip}:8927/sendspin
// - External: wss://{server}/sendspin (via MA's proxy with auth)
/// Update Sendspin player state
/// Similar to updateBuiltinPlayerState but for Sendspin protocol
+3 -4
View File
@@ -120,12 +120,11 @@ class SendspinService {
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
}
// Strategy 2: If external failed or server is HTTP, try local connection
// Note: We would need local_ws_url from getSendspinConnectionInfo API
// For now, skip this step and fall back to WebRTC (handled by provider)
// Note: Local network connections (ws://ip:8927/sendspin) are handled
// by the provider calling connectWithUrl() directly, not by this method.
if (!connected) {
_logger.log('Sendspin: External connection failed, WebRTC fallback needed');
_logger.log('Sendspin: External proxy connection failed');
_updateState(SendspinConnectionState.error);
return false;
}
+152 -13
View File
@@ -25,9 +25,17 @@ class ExpandablePlayer extends StatefulWidget {
/// Slide offset for hiding the mini player (0.0 = visible, 1.0 = hidden below screen)
final double slideOffset;
/// Bounce offset for the player reveal animation (moves mini player down slightly)
final double bounceOffset;
/// Callback when swipe-down gesture triggers player reveal
final VoidCallback? onRevealPlayers;
const ExpandablePlayer({
super.key,
this.slideOffset = 0.0,
this.bounceOffset = 0.0,
this.onRevealPlayers,
});
@override
@@ -91,6 +99,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
String? _lastMeasuredTrackName;
double? _lastMeasuredTitleWidth;
// Gesture-driven expansion state
bool _isVerticalDragging = false;
double _dragStartY = 0;
double _dragStartValue = 0;
// Maximum drag distance for full expand/collapse
static const double _maxDragDistance = 300.0;
// Thresholds for committing expand/collapse
static const double _commitPositionThreshold = 0.3; // 30% progress
static const double _commitVelocityThreshold = 800.0; // px/s
@override
void initState() {
super.initState();
@@ -98,6 +118,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
duration: const Duration(milliseconds: 300),
vsync: this,
);
// Simple curved animation - performant and smooth
_expandAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
@@ -182,23 +204,94 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
AnimationDebugger.recordFrame(_controller.value);
}
// Animation durations - asymmetric for snappier collapse
static const Duration _expandDuration = Duration(milliseconds: 280);
static const Duration _collapseDuration = Duration(milliseconds: 200);
void expand() {
if (_isVerticalDragging) return;
AnimationDebugger.startSession('playerExpand');
_controller.duration = _expandDuration;
_controller.forward().then((_) {
AnimationDebugger.endSession();
});
}
void collapse() {
if (_isVerticalDragging) return;
AnimationDebugger.startSession('playerCollapse');
// Instantly hide queue panel when collapsing to avoid visual glitches
// during Android's predictive back gesture
_queuePanelController.value = 0;
_controller.duration = _collapseDuration;
_controller.reverse().then((_) {
AnimationDebugger.endSession();
});
}
/// Handle vertical drag start - begin gesture-driven expansion
void _handleVerticalDragStart(DragStartDetails details) {
_isVerticalDragging = true;
_dragStartY = details.globalPosition.dy;
_dragStartValue = _controller.value;
_controller.stop(); // Stop any running animation
}
/// Handle vertical drag update - finger tracking
void _handleVerticalDragUpdate(DragUpdateDetails details) {
if (!_isVerticalDragging) return;
final dragDelta = _dragStartY - details.globalPosition.dy;
// Swipe up = positive delta = expand (increase value)
// Swipe down = negative delta = collapse (decrease value)
final normalizedDelta = dragDelta / _maxDragDistance;
final newValue = (_dragStartValue + normalizedDelta).clamp(0.0, 1.0);
_controller.value = newValue;
}
/// Handle vertical drag end - decide to commit or snap back
void _handleVerticalDragEnd(DragEndDetails details) {
if (!_isVerticalDragging) return;
_isVerticalDragging = false;
final velocity = details.primaryVelocity ?? 0;
final currentValue = _controller.value;
// Determine direction based on velocity first, then position
bool shouldExpand;
if (velocity.abs() > _commitVelocityThreshold) {
// High velocity: use velocity direction (negative = swipe up = expand)
shouldExpand = velocity < 0;
} else if (currentValue > _commitPositionThreshold && currentValue < (1 - _commitPositionThreshold)) {
// In the middle zone: use velocity direction if any, otherwise snap to nearest
shouldExpand = velocity < 0 || (velocity == 0 && currentValue > 0.5);
} else {
// Near edges: commit to nearest endpoint
shouldExpand = currentValue > 0.5;
}
// Animate to target with appropriate duration
if (shouldExpand) {
if (currentValue < 1.0) {
AnimationDebugger.startSession('playerExpand');
_controller.duration = _expandDuration;
_controller.forward().then((_) {
AnimationDebugger.endSession();
});
}
} else {
if (currentValue > 0.0) {
AnimationDebugger.startSession('playerCollapse');
_queuePanelController.value = 0;
_controller.duration = _collapseDuration;
_controller.reverse().then((_) {
AnimationDebugger.endSession();
});
}
}
}
bool get isExpanded => _controller.value > 0.5;
double get expansionProgress => _controller.value;
@@ -850,9 +943,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final bottomOffset = bottomNavSpace + _collapsedMargin;
final width = screenSize.width - (_collapsedMargin * 2);
// Apply slide offset for hiding
// Apply slide offset for hiding and bounce offset for reveal animation
final slideDownAmount = widget.slideOffset * (_collapsedHeight + bottomOffset + 20);
final adjustedBottomOffset = bottomOffset - slideDownAmount;
final bounceDownAmount = widget.bounceOffset;
final adjustedBottomOffset = bottomOffset - slideDownAmount - bounceDownAmount;
final availablePlayers = _getAvailablePlayersSorted(maProvider);
final hasMultiplePlayers = availablePlayers.length > 1;
@@ -861,7 +955,13 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
left: _collapsedMargin,
right: _collapsedMargin,
bottom: adjustedBottomOffset,
child: DeviceSelectorBar(
child: GestureDetector(
onVerticalDragUpdate: (details) {
if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) {
widget.onRevealPlayers!();
}
},
child: DeviceSelectorBar(
selectedPlayer: selectedPlayer,
peekPlayer: _peekPlayer,
hasMultiplePlayers: hasMultiplePlayers,
@@ -897,6 +997,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_handleHorizontalDragEnd(details, maProvider);
},
),
),
);
}
@@ -954,11 +1055,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final expandedHeight = screenSize.height - bottomNavSpace;
// Apply slide offset to hide mini player (slides down off-screen)
// Only apply when collapsed (t == 0), don't affect expanded state
// Apply bounce offset for reveal animation (small downward movement)
// Smoothly fade out offsets as animation progresses (continuous, no discontinuity)
final slideDownAmount = widget.slideOffset * (_collapsedHeight + collapsedBottomOffset + 20);
final slideAdjustedBottomOffset = t < 0.1
? collapsedBottomOffset - slideDownAmount
: _lerpDouble(collapsedBottomOffset, expandedBottomOffset, t);
final bounceDownAmount = widget.bounceOffset;
// Fade out slide/bounce effects smoothly over first 15% of animation
final offsetFade = (1.0 - (t / 0.15)).clamp(0.0, 1.0);
final baseBottomOffset = _lerpDouble(collapsedBottomOffset, expandedBottomOffset, t);
final slideAdjustedBottomOffset = baseBottomOffset - (slideDownAmount + bounceDownAmount) * offsetFade;
final collapsedWidth = screenSize.width - (_collapsedMargin * 2);
final width = _lerpDouble(collapsedWidth, screenSize.width, t);
@@ -1102,7 +1206,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final selectedPlayerId = selectedPlayer.playerId;
// Calculate slide offset for mini player content (only when collapsed)
final miniPlayerSlideOffset = t < 0.1 ? _slideOffset * collapsedWidth : 0.0;
// Smoothly fade out horizontal slide offset (for device switching) as animation progresses
final miniPlayerSlideOffset = _slideOffset * collapsedWidth * offsetFade;
return Positioned(
left: horizontalMargin,
@@ -1113,13 +1218,47 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
behavior: HitTestBehavior.translucent,
// Only handle tap when collapsed - when expanded, let children handle their own taps
onTap: isExpanded ? null : expand,
onVerticalDragUpdate: (details) {
if (details.primaryDelta! < -10 && !isExpanded) {
expand();
} else if (details.primaryDelta! > 10 && isExpanded && !isQueuePanelOpen) {
collapse();
onVerticalDragStart: (details) {
// For expanded player or queue panel: start tracking immediately
// For collapsed player: defer decision until we know swipe direction
if (isExpanded || isQueuePanelOpen) {
_handleVerticalDragStart(details);
}
},
onVerticalDragUpdate: (details) {
final delta = details.primaryDelta ?? 0;
// Handle queue panel close
if (isQueuePanelOpen && delta > 0) {
_toggleQueuePanel();
return;
}
// Collapsed + not yet tracking: decide based on direction
if (!isExpanded && !_isVerticalDragging) {
if (delta > 5 && widget.onRevealPlayers != null) {
// Swipe down → show player reveal
widget.onRevealPlayers!();
return;
} else if (delta < -5) {
// Swipe up → start expand tracking
_handleVerticalDragStart(DragStartDetails(
globalPosition: details.globalPosition,
localPosition: details.localPosition,
));
} else {
// Not enough movement yet, wait for more
return;
}
}
// Gesture-driven expand/collapse
_handleVerticalDragUpdate(details);
},
onVerticalDragEnd: (details) {
// Finish gesture-driven expansion
_handleVerticalDragEnd(details);
},
onHorizontalDragStart: (details) {
_horizontalDragStartX = details.globalPosition.dx;
},
+115 -3
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../theme/theme_provider.dart';
import 'expandable_player.dart';
import 'player/player_reveal_overlay.dart';
/// Cached color with contrast adjustment
/// Avoids expensive HSL conversions during scroll
@@ -111,13 +113,48 @@ class GlobalPlayerOverlay extends StatefulWidget {
static void showPlayer() {
_overlayStateKey.currentState?._setHidden(false);
}
/// Show the player reveal overlay with bounce animation
static void showPlayerReveal() {
_overlayStateKey.currentState?._showPlayerReveal();
}
/// Hide the player reveal overlay (no animation - used as callback from overlay)
static void hidePlayerReveal() {
_overlayStateKey.currentState?._hidePlayerReveal();
}
/// Dismiss the player reveal overlay with animation (for back gesture)
static void dismissPlayerReveal() {
_overlayStateKey.currentState?._dismissPlayerReveal();
}
/// Trigger the bounce animation on mini player (called when overlay dismiss starts)
static void triggerBounce() {
_overlayStateKey.currentState?._triggerBounce();
}
/// Check if the player reveal is currently visible
static bool get isPlayerRevealVisible =>
_overlayStateKey.currentState?._isRevealVisible ?? false;
}
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
with SingleTickerProviderStateMixin {
with TickerProviderStateMixin {
late AnimationController _slideController;
late Animation<double> _slideAnimation;
// Controller for player reveal animation with bounce
late AnimationController _bounceController;
late Animation<double> _bounceAnimation;
// State for player reveal overlay
bool _isRevealVisible = false;
double _bounceOffset = 0.0;
// Key for the reveal overlay
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
@override
void initState() {
super.initState();
@@ -130,11 +167,31 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
// Bounce animation - quick dip down then back up
_bounceController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_bounceAnimation = CurvedAnimation(
parent: _bounceController,
curve: Curves.easeOut,
);
_bounceController.addListener(() {
setState(() {
// Quick dip: goes down 10px at peak (0.5), then back to 0
final t = _bounceAnimation.value;
// Sine curve: 0 -> 1 -> 0 as t goes 0 -> 0.5 -> 1
_bounceOffset = 10.0 * (t < 0.5 ? t * 2 : (1.0 - t) * 2);
});
});
}
@override
void dispose() {
_slideController.dispose();
_bounceController.dispose();
super.dispose();
}
@@ -146,12 +203,55 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
}
}
void _showPlayerReveal() {
if (_isRevealVisible) return;
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
HapticFeedback.mediumImpact();
_bounceController.reset();
setState(() {
_isRevealVisible = true;
});
_bounceController.forward();
}
void _hidePlayerReveal() {
if (!_isRevealVisible) return;
setState(() {
_isRevealVisible = false;
_bounceOffset = 0;
});
}
/// Dismiss with animation (for back gesture) - calls overlay's dismiss method
void _dismissPlayerReveal() {
if (!_isRevealVisible) return;
// Call the overlay's dismiss method which has the slide animation
// Note: dismiss() triggers the bounce animation internally
_revealKey.currentState?.dismiss();
}
/// Trigger bounce animation on mini player
void _triggerBounce() {
_bounceController.reset();
_bounceController.forward();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Stack(
// Handle back gesture at top level - dismiss device list if visible
return PopScope(
canPop: !_isRevealVisible,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isRevealVisible) {
_dismissPlayerReveal();
}
},
child: Stack(
children: [
// The main app content (Navigator, screens, etc.)
// Add bottom padding to account for bottom nav + mini player
@@ -259,7 +359,16 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
},
),
),
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav
// Player reveal overlay - renders BELOW mini player so cards slide behind it
if (_isRevealVisible)
PlayerRevealOverlay(
key: _revealKey,
onDismiss: _hidePlayerReveal,
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
miniPlayerHeight: 64,
),
// Global player overlay - renders ON TOP so cards slide behind it
// Use Selector instead of Consumer to avoid rebuilds during animation
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
selector: (_, provider) => (
@@ -279,12 +388,15 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
return ExpandablePlayer(
key: globalPlayerKey,
slideOffset: _slideAnimation.value,
bounceOffset: _bounceOffset,
onRevealPlayers: _showPlayerReveal,
);
},
);
},
),
],
),
);
}
}
+42 -25
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../theme/design_tokens.dart';
/// A compact device selector bar shown when no track is playing
class DeviceSelectorBar extends StatelessWidget {
@@ -58,17 +57,26 @@ class DeviceSelectorBar extends StatelessWidget {
// Current player content
Transform.translate(
offset: Offset(slideOffset * width, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(selectedPlayer.name),
color: textColor,
size: IconSizes.md,
child: Row(
children: [
// Speaker icon - same size as album art for consistent text alignment
SizedBox(
width: height,
height: height,
child: Container(
color: Color.lerp(backgroundColor, Colors.black, 0.15),
child: Center(
child: Icon(
_getPlayerIcon(selectedPlayer.name),
color: textColor.withOpacity(0.4),
size: 28,
),
),
),
Spacing.hGap12,
Expanded(
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -94,8 +102,8 @@ class DeviceSelectorBar extends StatelessWidget {
],
),
),
],
),
),
],
),
),
],
@@ -118,17 +126,26 @@ class DeviceSelectorBar extends StatelessWidget {
return Transform.translate(
offset: Offset(peekBaseOffset, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(peekPlayer.name),
color: textColor,
size: IconSizes.md,
child: Row(
children: [
// Speaker icon - same size as album art for consistent text alignment
SizedBox(
width: height,
height: height,
child: Container(
color: Color.lerp(backgroundColor, Colors.black, 0.15),
child: Center(
child: Icon(
_getPlayerIcon(peekPlayer.name),
color: textColor.withOpacity(0.4),
size: 28,
),
),
),
Spacing.hGap12,
Expanded(
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -154,8 +171,8 @@ class DeviceSelectorBar extends StatelessWidget {
],
),
),
],
),
),
],
),
);
}
+233
View File
@@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart';
import '../../l10n/app_localizations.dart';
/// A player card that matches the mini player's visual style.
/// Used in the player reveal overlay when swiping down or tapping the device button.
class PlayerCard extends StatelessWidget {
final dynamic player;
final dynamic trackInfo;
final String? albumArtUrl;
final bool isSelected;
final bool isPlaying;
final Color backgroundColor;
final Color textColor;
final VoidCallback onTap;
final VoidCallback? onPlayPause;
final VoidCallback? onSkipNext;
final VoidCallback? onPower;
const PlayerCard({
super.key,
required this.player,
this.trackInfo,
this.albumArtUrl,
required this.isSelected,
required this.isPlaying,
required this.backgroundColor,
required this.textColor,
required this.onTap,
this.onPlayPause,
this.onSkipNext,
this.onPower,
});
@override
Widget build(BuildContext context) {
// Match mini player dimensions
const double cardHeight = Dimensions.miniPlayerHeight; // 64px
const double artSize = cardHeight;
const double borderRadius = Radii.xl; // 16px - same as mini player
return GestureDetector(
onTap: onTap,
child: Container(
height: cardHeight,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: Row(
children: [
// Album art or speaker icon - same size for consistent text alignment
SizedBox(
width: artSize,
height: artSize,
child: albumArtUrl != null
? CachedNetworkImage(
imageUrl: albumArtUrl!,
fit: BoxFit.cover,
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => _buildSpeakerIcon(),
errorWidget: (_, __, ___) => _buildSpeakerIcon(),
)
: _buildSpeakerIcon(),
),
// Player info
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Player name
Row(
children: [
// Status indicator
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getStatusColor(),
),
),
Expanded(
child: Text(
player.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 2),
// Track name or status
Text(
_getSubtitle(context),
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 14,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
// Transport controls - compact sizing to align with mini player
// Play/Pause and Next only shown when powered with content
if (player.available && player.powered && trackInfo != null) ...[
// Play/Pause - slight nudge right
Transform.translate(
offset: const Offset(3, 0),
child: SizedBox(
width: 28,
height: 28,
child: IconButton(
icon: Icon(
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: textColor,
size: 28,
),
onPressed: onPlayPause,
padding: EdgeInsets.zero,
),
),
),
// Skip next - nudged right to close gap with power
Transform.translate(
offset: const Offset(6, 0),
child: IconButton(
icon: Icon(
Icons.skip_next_rounded,
color: textColor,
size: 28,
),
onPressed: onSkipNext,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
],
// Power button - smallest
if (player.available)
IconButton(
icon: Icon(
Icons.power_settings_new_rounded,
color: player.powered ? textColor : textColor.withOpacity(0.5),
size: 20,
),
onPressed: onPower,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
],
),
),
);
}
Widget _buildSpeakerIcon() {
// Darker shade of the card background to match album art square
final iconBgColor = Color.lerp(backgroundColor, Colors.black, 0.15)!;
return Container(
color: iconBgColor,
child: Center(
child: Icon(
Icons.speaker_rounded,
color: textColor.withOpacity(0.4),
size: 28,
),
),
);
}
Color _getStatusColor() {
if (!player.available) {
return Colors.grey.withOpacity(0.5);
}
if (!player.powered) {
return Colors.grey;
}
if (isPlaying) {
return Colors.green;
}
if (trackInfo != null) {
return Colors.orange; // Has content but paused
}
return Colors.grey.shade400; // Idle
}
String _getSubtitle(BuildContext context) {
if (!player.available) {
return S.of(context)!.playerStateUnavailable;
}
if (!player.powered) {
return S.of(context)!.playerStateOff;
}
if (trackInfo != null) {
return trackInfo.name ?? S.of(context)!.playerStateIdle;
}
return S.of(context)!.playerStateIdle;
}
}
@@ -0,0 +1,307 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart';
import '../../theme/theme_provider.dart';
import '../../theme/palette_helper.dart';
import '../../constants/timings.dart';
import '../global_player_overlay.dart';
import 'player_card.dart';
/// Overlay that reveals all available players with a staggered slide-up animation.
/// Triggered by swiping down on the mini player or tapping the device button.
class PlayerRevealOverlay extends StatefulWidget {
final VoidCallback onDismiss;
final double miniPlayerBottom; // Bottom position of mini player
final double miniPlayerHeight;
const PlayerRevealOverlay({
super.key,
required this.onDismiss,
required this.miniPlayerBottom,
required this.miniPlayerHeight,
});
@override
State<PlayerRevealOverlay> createState() => PlayerRevealOverlayState();
}
class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
with TickerProviderStateMixin {
late AnimationController _revealController;
late Animation<double> _revealAnimation;
late Animation<double> _backdropAnimation;
Timer? _refreshTimer;
// Track vertical drag for dismissal
double _dragOffset = 0;
bool _isDragging = false;
// Cache extracted colors per player for per-device accent colors
final Map<String, ColorScheme?> _playerColors = {};
@override
void initState() {
super.initState();
_revealController = AnimationController(
vsync: this,
);
// Quick smooth animation with subtle overshoot
_revealAnimation = CurvedAnimation(
parent: _revealController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_backdropAnimation = CurvedAnimation(
parent: _revealController,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
);
// Preload all player track info and extract colors
_preloadColorsForPlayers();
// Start reveal animation
_revealController.duration = const Duration(milliseconds: 200);
_revealController.forward();
// Auto-refresh player data
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
if (mounted) {
final provider = context.read<MusicAssistantProvider>();
provider.refreshPlayers();
provider.preloadAllPlayerTracks().then((_) => _preloadColorsForPlayers());
}
});
}
/// Extract accent colors from album art for each player
Future<void> _preloadColorsForPlayers() async {
final maProvider = context.read<MusicAssistantProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
await maProvider.preloadAllPlayerTracks();
final players = maProvider.availablePlayers;
for (final player in players) {
if (_playerColors.containsKey(player.playerId)) continue;
final track = maProvider.getCachedTrackForPlayer(player.playerId);
if (track == null) continue;
final imageUrl = maProvider.getImageUrl(track, size: 128);
if (imageUrl == null) continue;
try {
final imageProvider = NetworkImage(imageUrl);
final schemes = await PaletteHelper.extractColorSchemes(imageProvider);
if (mounted && schemes != null) {
final colorScheme = isDark ? schemes.$2 : schemes.$1;
setState(() {
_playerColors[player.playerId] = colorScheme;
});
}
} catch (_) {
// Ignore color extraction errors
}
}
}
@override
void dispose() {
_revealController.dispose();
_refreshTimer?.cancel();
super.dispose();
}
/// Animate reveal (called from parent)
void reveal() {
_revealController.duration = const Duration(milliseconds: 200);
_revealController.forward();
}
/// Animate dismissal and call callback when done
void dismiss() {
// Trigger settle bounce on mini player when cards are ~70% collapsed
// This creates the effect of cards "landing" on the mini player
bool bounceFired = false;
void checkBounce() {
if (!bounceFired && _revealController.value < 0.3) {
bounceFired = true;
GlobalPlayerOverlay.triggerBounce();
}
}
_revealController.duration = const Duration(milliseconds: 150);
_revealController.addListener(checkBounce);
_revealController.reverse().then((_) {
_revealController.removeListener(checkBounce);
widget.onDismiss();
});
}
void _handleVerticalDragUpdate(DragUpdateDetails details) {
setState(() {
_isDragging = true;
// Negate delta so swipe down = cards move down (toward dismissal)
_dragOffset -= details.primaryDelta ?? 0;
// Clamp: allow moving down (positive) freely, limit moving up to 20px
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
});
}
void _handleVerticalDragEnd(DragEndDetails details) {
final velocity = details.primaryVelocity ?? 0;
// Dismiss if dragged down enough or with enough velocity
if (_dragOffset > 100 || velocity > 500) {
HapticFeedback.lightImpact();
// Reset drag offset before dismiss so animation starts from correct position
setState(() {
_isDragging = false;
_dragOffset = 0;
});
dismiss();
} else {
// Spring back
setState(() {
_isDragging = false;
_dragOffset = 0;
});
}
}
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Back gesture is handled at GlobalPlayerOverlay level
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
final allPlayers = maProvider.availablePlayers;
final selectedPlayer = maProvider.selectedPlayer;
final currentTrack = maProvider.currentTrack;
// Filter out the selected player - it's already shown in the mini player
final players = allPlayers
.where((p) => p.playerId != selectedPlayer?.playerId)
.toList();
// Default card colors - used when player doesn't have extracted colors
final defaultBgColor = colorScheme.primaryContainer;
final defaultTextColor = colorScheme.onPrimaryContainer;
// Calculate total stack height for animation
const cardHeight = 64.0;
const cardSpacing = 12.0;
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing;
return AnimatedBuilder(
animation: _revealAnimation,
builder: (context, child) {
final t = _revealAnimation.value;
return Stack(
children: [
// Backdrop - tap to dismiss
Positioned.fill(
child: GestureDetector(
onTap: dismiss,
child: Container(
color: Colors.black.withOpacity(0.6 * _backdropAnimation.value),
),
),
),
// Player cards - positioned above mini player
Positioned(
left: 12,
right: 12,
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset,
child: GestureDetector(
onVerticalDragUpdate: _handleVerticalDragUpdate,
onVerticalDragEnd: _handleVerticalDragEnd,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Build player cards - all slide from behind mini player
...List.generate(players.length, (index) {
final player = players[index];
final isPlaying = player.state == 'playing';
// Get track info for this player
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
// Get album art URL - use track directly like mini player does
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
// Use per-player colors if available, otherwise use defaults
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
// Animation: all cards start hidden behind mini player
// and fan out to their final positions with spring physics
const baseOffset = 80.0;
final reverseIndex = players.length - 1 - index;
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
// Use the elastic animation value directly (already has bounce)
final slideOffset = distanceToTravel * (1.0 - t);
return Transform.translate(
offset: Offset(0, slideOffset),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PlayerCard(
player: player,
trackInfo: playerTrack,
albumArtUrl: albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
backgroundColor: cardBgColor,
textColor: cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(player);
dismiss();
},
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
} else {
maProvider.resumePlayer(player.playerId);
}
},
onSkipNext: () {
maProvider.nextTrack(player.playerId);
},
onPower: () {
maProvider.togglePower(player.playerId);
},
),
),
);
}),
],
),
),
),
],
);
},
);
},
);
}
}
+2 -21
View File
@@ -64,27 +64,8 @@ class PlayerSelector extends StatelessWidget {
MusicAssistantProvider provider,
List players,
) {
// Collapse player if expanded before showing sheet
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
// Show the bottom sheet
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
isDismissible: true,
builder: (context) {
return _PlayerSelectorSheet();
},
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
// Use the new player reveal animation
GlobalPlayerOverlay.showPlayerReveal();
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.3.2-beta+24
version: 2.3.3-beta+25
environment:
sdk: '>=3.0.0 <4.0.0'