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