Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7bfb66fab | ||
|
|
222a66db61 | ||
|
|
8eec9599f3 | ||
|
|
fe45a34c35 | ||
|
|
232862b164 | ||
|
|
dc6004ac00 | ||
|
|
62fc5ab2f0 | ||
|
|
807bebbab9 | ||
|
|
7edf7bbfc8 | ||
|
|
c4ad0a6279 | ||
|
|
6ebf2ed15b | ||
|
|
91b713aab8 | ||
|
|
8ce567a1cf | ||
|
|
2081cf8552 | ||
|
|
8dae062663 | ||
|
|
59eb80c11b | ||
|
|
d118dd8fe1 | ||
|
|
64b36d37ad | ||
|
|
339e55df93 | ||
|
|
86f3798637 | ||
|
|
d5c96880d7 | ||
|
|
540b95f558 | ||
|
|
a1c84f29c1 | ||
|
|
2cf3135f18 | ||
|
|
1b33c57cb7 |
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user