Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 08:53:38 +00:00
Your Name 64b36d37ad Revert test: restore normal mini player colors 2025-12-23 07:32:09 +00:00
Your Name 339e55df93 TEST: Bright red mini player background to verify build freshness 2025-12-23 07:13:48 +00:00
Your Name 86f3798637 Add unified device UI with swipe-down reveal animation
Cherry-picked commits 39f43cc through d0ffd4a from feature/unified-device-animation
to get clean build state before Gradle issues.
2025-12-23 06:57:44 +00:00
9 changed files with 934 additions and 66 deletions
+10 -1
View File
@@ -172,11 +172,20 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
@override
Future<bool> didPopRoute() async {
// Intercept back button at app level - runs BEFORE Navigator processes it
// If player is expanded, collapse it and consume the back gesture
// Priority order: device list > expanded player > normal navigation
// Device list has highest priority - dismiss it first
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
GlobalPlayerOverlay.dismissPlayerReveal();
return true; // We handled it, don't let Navigator process it
}
// Then check expanded player
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
return true; // We handled it, don't let Navigator process it
}
// Let Navigator handle the back gesture normally
return super.didPopRoute();
}
+67 -3
View File
@@ -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
+6
View File
@@ -62,6 +62,12 @@ class _HomeScreenState extends State<HomeScreen> {
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
// If player reveal (device list) is visible, dismiss it first (with animation)
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
GlobalPlayerOverlay.dismissPlayerReveal();
return;
}
// If global player is expanded, collapse it first
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
+152 -13
View File
@@ -25,9 +25,17 @@ class ExpandablePlayer extends StatefulWidget {
/// Slide offset for hiding the mini player (0.0 = visible, 1.0 = hidden below screen)
final double slideOffset;
/// Bounce offset for the player reveal animation (moves mini player down slightly)
final double bounceOffset;
/// Callback when swipe-down gesture triggers player reveal
final VoidCallback? onRevealPlayers;
const ExpandablePlayer({
super.key,
this.slideOffset = 0.0,
this.bounceOffset = 0.0,
this.onRevealPlayers,
});
@override
@@ -91,6 +99,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
String? _lastMeasuredTrackName;
double? _lastMeasuredTitleWidth;
// Gesture-driven expansion state
bool _isVerticalDragging = false;
double _dragStartY = 0;
double _dragStartValue = 0;
// Maximum drag distance for full expand/collapse
static const double _maxDragDistance = 300.0;
// Thresholds for committing expand/collapse
static const double _commitPositionThreshold = 0.3; // 30% progress
static const double _commitVelocityThreshold = 800.0; // px/s
@override
void initState() {
super.initState();
@@ -98,6 +118,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
duration: const Duration(milliseconds: 300),
vsync: this,
);
// Simple curved animation - performant and smooth
_expandAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
@@ -182,23 +204,94 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
AnimationDebugger.recordFrame(_controller.value);
}
// Animation durations - asymmetric for snappier collapse
static const Duration _expandDuration = Duration(milliseconds: 280);
static const Duration _collapseDuration = Duration(milliseconds: 200);
void expand() {
if (_isVerticalDragging) return;
AnimationDebugger.startSession('playerExpand');
_controller.duration = _expandDuration;
_controller.forward().then((_) {
AnimationDebugger.endSession();
});
}
void collapse() {
if (_isVerticalDragging) return;
AnimationDebugger.startSession('playerCollapse');
// Instantly hide queue panel when collapsing to avoid visual glitches
// during Android's predictive back gesture
_queuePanelController.value = 0;
_controller.duration = _collapseDuration;
_controller.reverse().then((_) {
AnimationDebugger.endSession();
});
}
/// Handle vertical drag start - begin gesture-driven expansion
void _handleVerticalDragStart(DragStartDetails details) {
_isVerticalDragging = true;
_dragStartY = details.globalPosition.dy;
_dragStartValue = _controller.value;
_controller.stop(); // Stop any running animation
}
/// Handle vertical drag update - finger tracking
void _handleVerticalDragUpdate(DragUpdateDetails details) {
if (!_isVerticalDragging) return;
final dragDelta = _dragStartY - details.globalPosition.dy;
// Swipe up = positive delta = expand (increase value)
// Swipe down = negative delta = collapse (decrease value)
final normalizedDelta = dragDelta / _maxDragDistance;
final newValue = (_dragStartValue + normalizedDelta).clamp(0.0, 1.0);
_controller.value = newValue;
}
/// Handle vertical drag end - decide to commit or snap back
void _handleVerticalDragEnd(DragEndDetails details) {
if (!_isVerticalDragging) return;
_isVerticalDragging = false;
final velocity = details.primaryVelocity ?? 0;
final currentValue = _controller.value;
// Determine direction based on velocity first, then position
bool shouldExpand;
if (velocity.abs() > _commitVelocityThreshold) {
// High velocity: use velocity direction (negative = swipe up = expand)
shouldExpand = velocity < 0;
} else if (currentValue > _commitPositionThreshold && currentValue < (1 - _commitPositionThreshold)) {
// In the middle zone: use velocity direction if any, otherwise snap to nearest
shouldExpand = velocity < 0 || (velocity == 0 && currentValue > 0.5);
} else {
// Near edges: commit to nearest endpoint
shouldExpand = currentValue > 0.5;
}
// Animate to target with appropriate duration
if (shouldExpand) {
if (currentValue < 1.0) {
AnimationDebugger.startSession('playerExpand');
_controller.duration = _expandDuration;
_controller.forward().then((_) {
AnimationDebugger.endSession();
});
}
} else {
if (currentValue > 0.0) {
AnimationDebugger.startSession('playerCollapse');
_queuePanelController.value = 0;
_controller.duration = _collapseDuration;
_controller.reverse().then((_) {
AnimationDebugger.endSession();
});
}
}
}
bool get isExpanded => _controller.value > 0.5;
double get expansionProgress => _controller.value;
@@ -850,9 +943,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final bottomOffset = bottomNavSpace + _collapsedMargin;
final width = screenSize.width - (_collapsedMargin * 2);
// Apply slide offset for hiding
// Apply slide offset for hiding and bounce offset for reveal animation
final slideDownAmount = widget.slideOffset * (_collapsedHeight + bottomOffset + 20);
final adjustedBottomOffset = bottomOffset - slideDownAmount;
final bounceDownAmount = widget.bounceOffset;
final adjustedBottomOffset = bottomOffset - slideDownAmount - bounceDownAmount;
final availablePlayers = _getAvailablePlayersSorted(maProvider);
final hasMultiplePlayers = availablePlayers.length > 1;
@@ -861,7 +955,13 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
left: _collapsedMargin,
right: _collapsedMargin,
bottom: adjustedBottomOffset,
child: DeviceSelectorBar(
child: GestureDetector(
onVerticalDragUpdate: (details) {
if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) {
widget.onRevealPlayers!();
}
},
child: DeviceSelectorBar(
selectedPlayer: selectedPlayer,
peekPlayer: _peekPlayer,
hasMultiplePlayers: hasMultiplePlayers,
@@ -897,6 +997,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_handleHorizontalDragEnd(details, maProvider);
},
),
),
);
}
@@ -954,11 +1055,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final expandedHeight = screenSize.height - bottomNavSpace;
// Apply slide offset to hide mini player (slides down off-screen)
// Only apply when collapsed (t == 0), don't affect expanded state
// Apply bounce offset for reveal animation (small downward movement)
// Smoothly fade out offsets as animation progresses (continuous, no discontinuity)
final slideDownAmount = widget.slideOffset * (_collapsedHeight + collapsedBottomOffset + 20);
final slideAdjustedBottomOffset = t < 0.1
? collapsedBottomOffset - slideDownAmount
: _lerpDouble(collapsedBottomOffset, expandedBottomOffset, t);
final bounceDownAmount = widget.bounceOffset;
// Fade out slide/bounce effects smoothly over first 15% of animation
final offsetFade = (1.0 - (t / 0.15)).clamp(0.0, 1.0);
final baseBottomOffset = _lerpDouble(collapsedBottomOffset, expandedBottomOffset, t);
final slideAdjustedBottomOffset = baseBottomOffset - (slideDownAmount + bounceDownAmount) * offsetFade;
final collapsedWidth = screenSize.width - (_collapsedMargin * 2);
final width = _lerpDouble(collapsedWidth, screenSize.width, t);
@@ -1102,7 +1206,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final selectedPlayerId = selectedPlayer.playerId;
// Calculate slide offset for mini player content (only when collapsed)
final miniPlayerSlideOffset = t < 0.1 ? _slideOffset * collapsedWidth : 0.0;
// Smoothly fade out horizontal slide offset (for device switching) as animation progresses
final miniPlayerSlideOffset = _slideOffset * collapsedWidth * offsetFade;
return Positioned(
left: horizontalMargin,
@@ -1113,13 +1218,47 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
behavior: HitTestBehavior.translucent,
// Only handle tap when collapsed - when expanded, let children handle their own taps
onTap: isExpanded ? null : expand,
onVerticalDragUpdate: (details) {
if (details.primaryDelta! < -10 && !isExpanded) {
expand();
} else if (details.primaryDelta! > 10 && isExpanded && !isQueuePanelOpen) {
collapse();
onVerticalDragStart: (details) {
// For expanded player or queue panel: start tracking immediately
// For collapsed player: defer decision until we know swipe direction
if (isExpanded || isQueuePanelOpen) {
_handleVerticalDragStart(details);
}
},
onVerticalDragUpdate: (details) {
final delta = details.primaryDelta ?? 0;
// Handle queue panel close
if (isQueuePanelOpen && delta > 0) {
_toggleQueuePanel();
return;
}
// Collapsed + not yet tracking: decide based on direction
if (!isExpanded && !_isVerticalDragging) {
if (delta > 5 && widget.onRevealPlayers != null) {
// Swipe down → show player reveal
widget.onRevealPlayers!();
return;
} else if (delta < -5) {
// Swipe up → start expand tracking
_handleVerticalDragStart(DragStartDetails(
globalPosition: details.globalPosition,
localPosition: details.localPosition,
));
} else {
// Not enough movement yet, wait for more
return;
}
}
// Gesture-driven expand/collapse
_handleVerticalDragUpdate(details);
},
onVerticalDragEnd: (details) {
// Finish gesture-driven expansion
_handleVerticalDragEnd(details);
},
onHorizontalDragStart: (details) {
_horizontalDragStartX = details.globalPosition.dx;
},
+115 -3
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../theme/theme_provider.dart';
import 'expandable_player.dart';
import 'player/player_reveal_overlay.dart';
/// Cached color with contrast adjustment
/// Avoids expensive HSL conversions during scroll
@@ -111,13 +113,48 @@ class GlobalPlayerOverlay extends StatefulWidget {
static void showPlayer() {
_overlayStateKey.currentState?._setHidden(false);
}
/// Show the player reveal overlay with bounce animation
static void showPlayerReveal() {
_overlayStateKey.currentState?._showPlayerReveal();
}
/// Hide the player reveal overlay (no animation - used as callback from overlay)
static void hidePlayerReveal() {
_overlayStateKey.currentState?._hidePlayerReveal();
}
/// Dismiss the player reveal overlay with animation (for back gesture)
static void dismissPlayerReveal() {
_overlayStateKey.currentState?._dismissPlayerReveal();
}
/// Trigger the bounce animation on mini player (called when overlay dismiss starts)
static void triggerBounce() {
_overlayStateKey.currentState?._triggerBounce();
}
/// Check if the player reveal is currently visible
static bool get isPlayerRevealVisible =>
_overlayStateKey.currentState?._isRevealVisible ?? false;
}
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
with SingleTickerProviderStateMixin {
with TickerProviderStateMixin {
late AnimationController _slideController;
late Animation<double> _slideAnimation;
// Controller for player reveal animation with bounce
late AnimationController _bounceController;
late Animation<double> _bounceAnimation;
// State for player reveal overlay
bool _isRevealVisible = false;
double _bounceOffset = 0.0;
// Key for the reveal overlay
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
@override
void initState() {
super.initState();
@@ -130,11 +167,31 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
// Bounce animation - quick dip down then back up
_bounceController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_bounceAnimation = CurvedAnimation(
parent: _bounceController,
curve: Curves.easeOut,
);
_bounceController.addListener(() {
setState(() {
// Quick dip: goes down 10px at peak (0.5), then back to 0
final t = _bounceAnimation.value;
// Sine curve: 0 -> 1 -> 0 as t goes 0 -> 0.5 -> 1
_bounceOffset = 10.0 * (t < 0.5 ? t * 2 : (1.0 - t) * 2);
});
});
}
@override
void dispose() {
_slideController.dispose();
_bounceController.dispose();
super.dispose();
}
@@ -146,12 +203,55 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
}
}
void _showPlayerReveal() {
if (_isRevealVisible) return;
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
HapticFeedback.mediumImpact();
_bounceController.reset();
setState(() {
_isRevealVisible = true;
});
_bounceController.forward();
}
void _hidePlayerReveal() {
if (!_isRevealVisible) return;
setState(() {
_isRevealVisible = false;
_bounceOffset = 0;
});
}
/// Dismiss with animation (for back gesture) - calls overlay's dismiss method
void _dismissPlayerReveal() {
if (!_isRevealVisible) return;
// Call the overlay's dismiss method which has the slide animation
// Note: dismiss() triggers the bounce animation internally
_revealKey.currentState?.dismiss();
}
/// Trigger bounce animation on mini player
void _triggerBounce() {
_bounceController.reset();
_bounceController.forward();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Stack(
// Handle back gesture at top level - dismiss device list if visible
return PopScope(
canPop: !_isRevealVisible,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isRevealVisible) {
_dismissPlayerReveal();
}
},
child: Stack(
children: [
// The main app content (Navigator, screens, etc.)
// Add bottom padding to account for bottom nav + mini player
@@ -259,7 +359,16 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
},
),
),
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav
// Player reveal overlay - renders BELOW mini player so cards slide behind it
if (_isRevealVisible)
PlayerRevealOverlay(
key: _revealKey,
onDismiss: _hidePlayerReveal,
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
miniPlayerHeight: 64,
),
// Global player overlay - renders ON TOP so cards slide behind it
// Use Selector instead of Consumer to avoid rebuilds during animation
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
selector: (_, provider) => (
@@ -279,12 +388,15 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
return ExpandablePlayer(
key: globalPlayerKey,
slideOffset: _slideAnimation.value,
bounceOffset: _bounceOffset,
onRevealPlayers: _showPlayerReveal,
);
},
);
},
),
],
),
);
}
}
+42 -25
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../theme/design_tokens.dart';
/// A compact device selector bar shown when no track is playing
class DeviceSelectorBar extends StatelessWidget {
@@ -58,17 +57,26 @@ class DeviceSelectorBar extends StatelessWidget {
// Current player content
Transform.translate(
offset: Offset(slideOffset * width, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(selectedPlayer.name),
color: textColor,
size: IconSizes.md,
child: Row(
children: [
// Speaker icon - same size as album art for consistent text alignment
SizedBox(
width: height,
height: height,
child: Container(
color: Color.lerp(backgroundColor, Colors.black, 0.15),
child: Center(
child: Icon(
_getPlayerIcon(selectedPlayer.name),
color: textColor.withOpacity(0.4),
size: 28,
),
),
),
Spacing.hGap12,
Expanded(
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -94,8 +102,8 @@ class DeviceSelectorBar extends StatelessWidget {
],
),
),
],
),
),
],
),
),
],
@@ -118,17 +126,26 @@ class DeviceSelectorBar extends StatelessWidget {
return Transform.translate(
offset: Offset(peekBaseOffset, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(peekPlayer.name),
color: textColor,
size: IconSizes.md,
child: Row(
children: [
// Speaker icon - same size as album art for consistent text alignment
SizedBox(
width: height,
height: height,
child: Container(
color: Color.lerp(backgroundColor, Colors.black, 0.15),
child: Center(
child: Icon(
_getPlayerIcon(peekPlayer.name),
color: textColor.withOpacity(0.4),
size: 28,
),
),
),
Spacing.hGap12,
Expanded(
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -154,8 +171,8 @@ class DeviceSelectorBar extends StatelessWidget {
],
),
),
],
),
),
],
),
);
}
+233
View File
@@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart';
import '../../l10n/app_localizations.dart';
/// A player card that matches the mini player's visual style.
/// Used in the player reveal overlay when swiping down or tapping the device button.
class PlayerCard extends StatelessWidget {
final dynamic player;
final dynamic trackInfo;
final String? albumArtUrl;
final bool isSelected;
final bool isPlaying;
final Color backgroundColor;
final Color textColor;
final VoidCallback onTap;
final VoidCallback? onPlayPause;
final VoidCallback? onSkipNext;
final VoidCallback? onPower;
const PlayerCard({
super.key,
required this.player,
this.trackInfo,
this.albumArtUrl,
required this.isSelected,
required this.isPlaying,
required this.backgroundColor,
required this.textColor,
required this.onTap,
this.onPlayPause,
this.onSkipNext,
this.onPower,
});
@override
Widget build(BuildContext context) {
// Match mini player dimensions
const double cardHeight = Dimensions.miniPlayerHeight; // 64px
const double artSize = cardHeight;
const double borderRadius = Radii.xl; // 16px - same as mini player
return GestureDetector(
onTap: onTap,
child: Container(
height: cardHeight,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: Row(
children: [
// Album art or speaker icon - same size for consistent text alignment
SizedBox(
width: artSize,
height: artSize,
child: albumArtUrl != null
? CachedNetworkImage(
imageUrl: albumArtUrl!,
fit: BoxFit.cover,
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => _buildSpeakerIcon(),
errorWidget: (_, __, ___) => _buildSpeakerIcon(),
)
: _buildSpeakerIcon(),
),
// Player info
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Player name
Row(
children: [
// Status indicator
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getStatusColor(),
),
),
Expanded(
child: Text(
player.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 2),
// Track name or status
Text(
_getSubtitle(context),
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 14,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
// Transport controls - compact sizing to align with mini player
// Play/Pause and Next only shown when powered with content
if (player.available && player.powered && trackInfo != null) ...[
// Play/Pause - slight nudge right
Transform.translate(
offset: const Offset(3, 0),
child: SizedBox(
width: 28,
height: 28,
child: IconButton(
icon: Icon(
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: textColor,
size: 28,
),
onPressed: onPlayPause,
padding: EdgeInsets.zero,
),
),
),
// Skip next - nudged right to close gap with power
Transform.translate(
offset: const Offset(6, 0),
child: IconButton(
icon: Icon(
Icons.skip_next_rounded,
color: textColor,
size: 28,
),
onPressed: onSkipNext,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
],
// Power button - smallest
if (player.available)
IconButton(
icon: Icon(
Icons.power_settings_new_rounded,
color: player.powered ? textColor : textColor.withOpacity(0.5),
size: 20,
),
onPressed: onPower,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
],
),
),
);
}
Widget _buildSpeakerIcon() {
// Darker shade of the card background to match album art square
final iconBgColor = Color.lerp(backgroundColor, Colors.black, 0.15)!;
return Container(
color: iconBgColor,
child: Center(
child: Icon(
Icons.speaker_rounded,
color: textColor.withOpacity(0.4),
size: 28,
),
),
);
}
Color _getStatusColor() {
if (!player.available) {
return Colors.grey.withOpacity(0.5);
}
if (!player.powered) {
return Colors.grey;
}
if (isPlaying) {
return Colors.green;
}
if (trackInfo != null) {
return Colors.orange; // Has content but paused
}
return Colors.grey.shade400; // Idle
}
String _getSubtitle(BuildContext context) {
if (!player.available) {
return S.of(context)!.playerStateUnavailable;
}
if (!player.powered) {
return S.of(context)!.playerStateOff;
}
if (trackInfo != null) {
return trackInfo.name ?? S.of(context)!.playerStateIdle;
}
return S.of(context)!.playerStateIdle;
}
}
@@ -0,0 +1,307 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart';
import '../../theme/theme_provider.dart';
import '../../theme/palette_helper.dart';
import '../../constants/timings.dart';
import '../global_player_overlay.dart';
import 'player_card.dart';
/// Overlay that reveals all available players with a staggered slide-up animation.
/// Triggered by swiping down on the mini player or tapping the device button.
class PlayerRevealOverlay extends StatefulWidget {
final VoidCallback onDismiss;
final double miniPlayerBottom; // Bottom position of mini player
final double miniPlayerHeight;
const PlayerRevealOverlay({
super.key,
required this.onDismiss,
required this.miniPlayerBottom,
required this.miniPlayerHeight,
});
@override
State<PlayerRevealOverlay> createState() => PlayerRevealOverlayState();
}
class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
with TickerProviderStateMixin {
late AnimationController _revealController;
late Animation<double> _revealAnimation;
late Animation<double> _backdropAnimation;
Timer? _refreshTimer;
// Track vertical drag for dismissal
double _dragOffset = 0;
bool _isDragging = false;
// Cache extracted colors per player for per-device accent colors
final Map<String, ColorScheme?> _playerColors = {};
@override
void initState() {
super.initState();
_revealController = AnimationController(
vsync: this,
);
// Quick smooth animation with subtle overshoot
_revealAnimation = CurvedAnimation(
parent: _revealController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_backdropAnimation = CurvedAnimation(
parent: _revealController,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
);
// Preload all player track info and extract colors
_preloadColorsForPlayers();
// Start reveal animation
_revealController.duration = const Duration(milliseconds: 200);
_revealController.forward();
// Auto-refresh player data
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
if (mounted) {
final provider = context.read<MusicAssistantProvider>();
provider.refreshPlayers();
provider.preloadAllPlayerTracks().then((_) => _preloadColorsForPlayers());
}
});
}
/// Extract accent colors from album art for each player
Future<void> _preloadColorsForPlayers() async {
final maProvider = context.read<MusicAssistantProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
await maProvider.preloadAllPlayerTracks();
final players = maProvider.availablePlayers;
for (final player in players) {
if (_playerColors.containsKey(player.playerId)) continue;
final track = maProvider.getCachedTrackForPlayer(player.playerId);
if (track == null) continue;
final imageUrl = maProvider.getImageUrl(track, size: 128);
if (imageUrl == null) continue;
try {
final imageProvider = NetworkImage(imageUrl);
final schemes = await PaletteHelper.extractColorSchemes(imageProvider);
if (mounted && schemes != null) {
final colorScheme = isDark ? schemes.$2 : schemes.$1;
setState(() {
_playerColors[player.playerId] = colorScheme;
});
}
} catch (_) {
// Ignore color extraction errors
}
}
}
@override
void dispose() {
_revealController.dispose();
_refreshTimer?.cancel();
super.dispose();
}
/// Animate reveal (called from parent)
void reveal() {
_revealController.duration = const Duration(milliseconds: 200);
_revealController.forward();
}
/// Animate dismissal and call callback when done
void dismiss() {
// Trigger settle bounce on mini player when cards are ~70% collapsed
// This creates the effect of cards "landing" on the mini player
bool bounceFired = false;
void checkBounce() {
if (!bounceFired && _revealController.value < 0.3) {
bounceFired = true;
GlobalPlayerOverlay.triggerBounce();
}
}
_revealController.duration = const Duration(milliseconds: 150);
_revealController.addListener(checkBounce);
_revealController.reverse().then((_) {
_revealController.removeListener(checkBounce);
widget.onDismiss();
});
}
void _handleVerticalDragUpdate(DragUpdateDetails details) {
setState(() {
_isDragging = true;
// Negate delta so swipe down = cards move down (toward dismissal)
_dragOffset -= details.primaryDelta ?? 0;
// Clamp: allow moving down (positive) freely, limit moving up to 20px
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
});
}
void _handleVerticalDragEnd(DragEndDetails details) {
final velocity = details.primaryVelocity ?? 0;
// Dismiss if dragged down enough or with enough velocity
if (_dragOffset > 100 || velocity > 500) {
HapticFeedback.lightImpact();
// Reset drag offset before dismiss so animation starts from correct position
setState(() {
_isDragging = false;
_dragOffset = 0;
});
dismiss();
} else {
// Spring back
setState(() {
_isDragging = false;
_dragOffset = 0;
});
}
}
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Back gesture is handled at GlobalPlayerOverlay level
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
final allPlayers = maProvider.availablePlayers;
final selectedPlayer = maProvider.selectedPlayer;
final currentTrack = maProvider.currentTrack;
// Filter out the selected player - it's already shown in the mini player
final players = allPlayers
.where((p) => p.playerId != selectedPlayer?.playerId)
.toList();
// Default card colors - used when player doesn't have extracted colors
final defaultBgColor = colorScheme.primaryContainer;
final defaultTextColor = colorScheme.onPrimaryContainer;
// Calculate total stack height for animation
const cardHeight = 64.0;
const cardSpacing = 12.0;
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing;
return AnimatedBuilder(
animation: _revealAnimation,
builder: (context, child) {
final t = _revealAnimation.value;
return Stack(
children: [
// Backdrop - tap to dismiss
Positioned.fill(
child: GestureDetector(
onTap: dismiss,
child: Container(
color: Colors.black.withOpacity(0.6 * _backdropAnimation.value),
),
),
),
// Player cards - positioned above mini player
Positioned(
left: 12,
right: 12,
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset,
child: GestureDetector(
onVerticalDragUpdate: _handleVerticalDragUpdate,
onVerticalDragEnd: _handleVerticalDragEnd,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Build player cards - all slide from behind mini player
...List.generate(players.length, (index) {
final player = players[index];
final isPlaying = player.state == 'playing';
// Get track info for this player
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
// Get album art URL - use track directly like mini player does
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
// Use per-player colors if available, otherwise use defaults
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
// Animation: all cards start hidden behind mini player
// and fan out to their final positions with spring physics
const baseOffset = 80.0;
final reverseIndex = players.length - 1 - index;
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
// Use the elastic animation value directly (already has bounce)
final slideOffset = distanceToTravel * (1.0 - t);
return Transform.translate(
offset: Offset(0, slideOffset),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PlayerCard(
player: player,
trackInfo: playerTrack,
albumArtUrl: albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
backgroundColor: cardBgColor,
textColor: cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(player);
dismiss();
},
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
} else {
maProvider.resumePlayer(player.playerId);
}
},
onSkipNext: () {
maProvider.nextTrack(player.playerId);
},
onPower: () {
maProvider.togglePower(player.playerId);
},
),
),
);
}),
],
),
),
),
],
);
},
);
},
);
}
}
+2 -21
View File
@@ -64,27 +64,8 @@ class PlayerSelector extends StatelessWidget {
MusicAssistantProvider provider,
List players,
) {
// Collapse player if expanded before showing sheet
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
// Show the bottom sheet
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
isDismissible: true,
builder: (context) {
return _PlayerSelectorSheet();
},
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
// Use the new player reveal animation
GlobalPlayerOverlay.showPlayerReveal();
}
}