Files
Your NameandClaude Opus 4.5 8408db3abb perf: Optimize animations for butter-smooth 60-120fps
Phase 4 performance optimizations:

ExpandablePlayer:
- Cache MaterialRectCenterArcTween (recreate only on dimension change)
- Pre-cached static BoxShadow objects for mini player and art shadows
- Use Transform.translate for GPU-accelerated slide animations
- Pre-compute color opacities (textColor60, textColor70, textColor45)
- Use Color.lerp between pre-computed colors for artist text
- Throttle expansion progress notifications (notify on ≥1% change)
- Simplified shadow rendering with threshold-based visibility

PlayerRevealOverlay:
- Pre-compute player card data outside AnimatedBuilder
- Cache hint colors to avoid withOpacity() per frame
- Add _PlayerCardData helper class for pre-computed data

PlayerCard:
- Static const BoxShadow for card and volume overlay
- Already has RepaintBoundary (verified)

These changes reduce per-frame object allocations and move
slide animations to GPU compositing layer.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:14:14 +00:00

526 lines
18 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.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';
import '../../services/debug_logger.dart';
import '../../services/settings_service.dart';
final _volumeLogger = DebugLogger();
/// 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.
/// Supports horizontal swipe to adjust volume with a two-tone overlay.
class PlayerCard extends StatefulWidget {
final dynamic player;
final dynamic trackInfo;
final String? albumArtUrl;
final bool isSelected;
final bool isPlaying;
final bool isGrouped;
final Color backgroundColor;
final Color textColor;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final VoidCallback? onPlayPause;
final VoidCallback? onSkipNext;
final VoidCallback? onPower;
final ValueChanged<double>? onVolumeChange;
final ValueChanged<bool>? onPrecisionModeChanged;
// Pastel yellow for grouped players
static const Color groupBorderColor = Color(0xFFFFF59D);
// Precision mode settings
static const int precisionTriggerMs = 800; // Hold still for 800ms to enter precision mode
static const double precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
static const double precisionSensitivity = 0.1; // 10x more precise (full swipe = 10% change)
// PERF Phase 4: Pre-cached BoxShadow to avoid allocation per frame
static const BoxShadow cardShadow = BoxShadow(
color: Color(0x33000000), // 20% black
blurRadius: 8,
offset: Offset(0, 2),
);
const PlayerCard({
super.key,
required this.player,
this.trackInfo,
this.albumArtUrl,
required this.isSelected,
required this.isPlaying,
this.isGrouped = false,
required this.backgroundColor,
required this.textColor,
required this.onTap,
this.onLongPress,
this.onPlayPause,
this.onSkipNext,
this.onPower,
this.onVolumeChange,
this.onPrecisionModeChanged,
});
@override
State<PlayerCard> createState() => _PlayerCardState();
}
class _PlayerCardState extends State<PlayerCard> {
bool _isDraggingVolume = false;
double _dragVolumeLevel = 0.0;
double _cardWidth = 0.0;
int _lastVolumeUpdateTime = 0;
int _lastDragEndTime = 0; // Track when last drag ended for consecutive swipes
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
static const int _precisionThrottleMs = 50; // Faster updates in precision mode
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
// Precision mode state
bool _inPrecisionMode = false;
Timer? _precisionTimer;
Offset? _lastDragPosition;
double _lastLocalX = 0.0; // Last local X position during drag (for precision mode)
bool _precisionModeEnabled = true; // From settings
double _precisionZoomCenter = 0.0; // Volume level when precision mode started
double _precisionStartX = 0.0; // Finger X position when precision mode started
@override
void initState() {
super.initState();
_loadPrecisionModeSetting();
}
Future<void> _loadPrecisionModeSetting() async {
final enabled = await SettingsService.getVolumePrecisionMode();
if (mounted) {
setState(() {
_precisionModeEnabled = enabled;
});
}
}
@override
void dispose() {
_precisionTimer?.cancel();
super.dispose();
}
void _enterPrecisionMode() {
if (_inPrecisionMode) return;
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
setState(() {
_inPrecisionMode = true;
_precisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
_precisionStartX = _lastLocalX; // Capture finger position at entry
});
widget.onPrecisionModeChanged?.call(true);
_volumeLogger.debug(
'PRECISION_MODE [${widget.player.name}]: ENTERED at ${(_precisionZoomCenter * 100).round()}%',
context: 'Volume',
);
}
void _exitPrecisionMode() {
_precisionTimer?.cancel();
_precisionTimer = null;
if (!_inPrecisionMode) return;
setState(() {
_inPrecisionMode = false;
});
widget.onPrecisionModeChanged?.call(false);
_volumeLogger.debug(
'PRECISION_MODE [${widget.player.name}]: EXITED',
context: 'Volume',
);
}
@override
Widget build(BuildContext context) {
// Match mini player dimensions
const double cardHeight = Dimensions.miniPlayerHeight;
const double artSize = cardHeight;
const double borderRadius = Radii.xl; // 16px - same as mini player
// Colors for volume overlay (same as mini player progress bar)
final filledColor = widget.backgroundColor;
final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!;
// RepaintBoundary isolates this card's repaint from parent transform animations
// This improves performance during the staggered reveal animation
return RepaintBoundary(
child: LayoutBuilder(
builder: (context, constraints) {
_cardWidth = constraints.maxWidth;
return GestureDetector(
onTap: _isDraggingVolume ? null : widget.onTap,
onLongPress: _isDraggingVolume ? null : widget.onLongPress,
onHorizontalDragStart: _onDragStart,
onHorizontalDragUpdate: _onDragUpdate,
onHorizontalDragEnd: _onDragEnd,
onHorizontalDragCancel: _onDragCancel,
// Use Stack to render border ON TOP of content, preventing album art clipping
child: Stack(
children: [
// Main card content (hidden when dragging volume)
if (!_isDraggingVolume)
_buildCardContent(cardHeight, artSize, borderRadius),
// Volume overlay (shown when dragging)
if (_isDraggingVolume)
Container(
height: cardHeight,
decoration: BoxDecoration(
color: unfilledColor,
borderRadius: BorderRadius.circular(borderRadius),
// PERF Phase 4: Use pre-cached static BoxShadow
boxShadow: const [PlayerCard.cardShadow],
),
clipBehavior: Clip.antiAlias,
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
heightFactor: 1.0,
child: Container(
decoration: BoxDecoration(
color: filledColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(borderRadius),
bottomLeft: Radius.circular(borderRadius),
),
),
),
),
),
),
// Border overlay - renders ON TOP to prevent clipping by album art
if (widget.isGrouped)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(color: PlayerCard.groupBorderColor, width: 1.5),
),
),
),
],
),
);
},
),
);
}
Widget _buildCardContent(double cardHeight, double artSize, double borderRadius) {
return Container(
height: cardHeight,
decoration: BoxDecoration(
color: widget.backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
// PERF Phase 4: Use pre-cached static BoxShadow
boxShadow: const [PlayerCard.cardShadow],
),
clipBehavior: Clip.antiAlias,
child: Row(
children: [
// Album art or speaker icon - same size for consistent text alignment
SizedBox(
width: artSize,
height: artSize,
child: widget.albumArtUrl != null
? CachedNetworkImage(
imageUrl: widget.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(
widget.player.name,
style: TextStyle(
color: widget.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: widget.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 (widget.player.available && widget.player.powered && widget.trackInfo != null) ...[
// Play/Pause - slight nudge right
// Touch target increased to 44dp for accessibility (icon remains 28)
Transform.translate(
offset: const Offset(3, 0),
child: SizedBox(
width: 44,
height: 44,
child: IconButton(
icon: Icon(
widget.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: widget.textColor,
size: 28,
),
onPressed: widget.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: widget.textColor,
size: 28,
),
onPressed: widget.onSkipNext,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
],
// Power button - smallest
if (widget.player.available)
IconButton(
icon: Icon(
Icons.power_settings_new_rounded,
color: widget.player.powered ? widget.textColor : widget.textColor.withOpacity(0.5),
size: 20,
),
onPressed: widget.onPower,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
],
),
);
}
void _onDragStart(DragStartDetails details) {
// For consecutive swipes, use local volume (API may not have updated player state yet)
final now = DateTime.now().millisecondsSinceEpoch;
final timeSinceLastDrag = now - _lastDragEndTime;
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
// Use local volume if:
// 1. We have a local override (we've swiped before) AND
// 2. We're within the consecutive swipe window (5 seconds)
// Otherwise, read fresh from player state
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
final playerVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
final startVolume = useLocalVolume
? _dragVolumeLevel // Continue from where last swipe ended
: playerVolume; // Fresh from player
_volumeLogger.debug(
'DRAG_START [${widget.player.name}]: '
'useLocal=$useLocalVolume, hasOverride=$_hasLocalVolumeOverride, '
'inWindow=$isWithinWindow (${timeSinceLastDrag}ms ago), '
'localVol=${(_dragVolumeLevel * 100).round()}%, '
'playerVol=${(playerVolume * 100).round()}%, '
'startVol=${(startVolume * 100).round()}%',
context: 'Volume',
);
setState(() {
_isDraggingVolume = true;
_dragVolumeLevel = startVolume;
});
_lastDragPosition = details.globalPosition;
HapticFeedback.lightImpact();
}
void _onDragUpdate(DragUpdateDetails details) {
if (!_isDraggingVolume || _cardWidth <= 0) return;
// Track local X position for precision mode
_lastLocalX = details.localPosition.dx;
// Check for stillness to trigger precision mode (only if enabled in settings)
final currentPosition = details.globalPosition;
if (_precisionModeEnabled && _lastDragPosition != null) {
final movement = (currentPosition - _lastDragPosition!).distance;
if (movement < PlayerCard.precisionStillnessThreshold) {
// Finger is still - start precision timer if not already running
if (_precisionTimer == null && !_inPrecisionMode) {
_precisionTimer = Timer(
Duration(milliseconds: PlayerCard.precisionTriggerMs),
_enterPrecisionMode,
);
}
} else {
// Finger moved - cancel timer (but don't exit precision mode if already in it)
_precisionTimer?.cancel();
_precisionTimer = null;
}
}
_lastDragPosition = currentPosition;
double newVolume;
if (_inPrecisionMode) {
// PRECISION MODE: Movement from entry point maps to zoomed range
// Full card width of movement = precisionSensitivity (10%) change
// e.g., at 40% center: moving full card right = 50%, full card left = 30%
final offsetX = details.localPosition.dx - _precisionStartX;
final normalizedOffset = offsetX / _cardWidth; // -1.0 to +1.0 range
final volumeChange = normalizedOffset * PlayerCard.precisionSensitivity;
newVolume = (_precisionZoomCenter + volumeChange).clamp(0.0, 1.0);
} else {
// NORMAL MODE: Delta-based movement (full card width = 100%)
final dragDelta = details.delta.dx;
final volumeDelta = dragDelta / _cardWidth;
newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
}
// Always update visual
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
setState(() {
_dragVolumeLevel = newVolume;
});
// Throttle API calls to prevent flooding (faster in precision mode)
final now = DateTime.now().millisecondsSinceEpoch;
final throttleMs = _inPrecisionMode ? _precisionThrottleMs : _volumeThrottleMs;
if (now - _lastVolumeUpdateTime >= throttleMs) {
_lastVolumeUpdateTime = now;
widget.onVolumeChange?.call(newVolume);
}
}
}
void _onDragEnd(DragEndDetails details) {
// Send final volume on release
_volumeLogger.debug(
'DRAG_END [${widget.player.name}]: '
'finalVol=${(_dragVolumeLevel * 100).round()}%, '
'precisionMode=$_inPrecisionMode',
context: 'Volume',
);
widget.onVolumeChange?.call(_dragVolumeLevel);
_lastDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
_exitPrecisionMode();
_lastDragPosition = null;
setState(() {
_isDraggingVolume = false;
});
HapticFeedback.lightImpact();
}
void _onDragCancel() {
_exitPrecisionMode();
_lastDragPosition = null;
setState(() {
_isDraggingVolume = false;
});
}
Widget _buildSpeakerIcon() {
// Darker shade of the card background to match album art square
final iconBgColor = Color.lerp(widget.backgroundColor, Colors.black, 0.15)!;
return Container(
color: iconBgColor,
child: Center(
child: Icon(
Icons.speaker_rounded,
color: widget.textColor.withOpacity(0.4),
size: 28,
),
),
);
}
Color _getStatusColor() {
if (!widget.player.available) {
return Colors.grey.withOpacity(0.5);
}
if (!widget.player.powered) {
return Colors.grey;
}
if (widget.isPlaying) {
return Colors.green;
}
if (widget.trackInfo != null) {
return Colors.orange; // Has content but paused
}
return Colors.grey.shade400; // Idle
}
String _getSubtitle(BuildContext context) {
if (!widget.player.available) {
return S.of(context)!.playerStateUnavailable;
}
if (!widget.player.powered) {
return S.of(context)!.playerStateOff;
}
if (widget.trackInfo != null) {
return widget.trackInfo.name ?? S.of(context)!.playerStateIdle;
}
return S.of(context)!.playerStateIdle;
}
}