Remote Bridge: - Add WebRTC-based remote connection infrastructure - Add signaling client for MA remote access protocol - Add RemoteBridge local WebSocket server for transparent tunneling - Add RemoteConnectionManager for high-level remote API Flutter 3.38 Fixes: - Replace Material 3 Slider with custom implementation (fixes Overlay error) - Custom progress bar and volume slider using Stack/Container - Add seek debouncing to prevent command timeout on rapid seeks - Fix position reporting in Sendspin client/state payload Other Fixes: - Fix tab swiping scrolling multiple tabs (use default PageScrollPhysics) - Fix copy logs to use filtered entries instead of all logs - Revert share logs to simple implementation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
4260 lines
177 KiB
Dart
4260 lines
177 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/physics.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import '../constants/timings.dart';
|
|
import '../utils/duration_formatter.dart';
|
|
import '../providers/music_assistant_provider.dart';
|
|
import '../providers/navigation_provider.dart';
|
|
import '../models/player.dart';
|
|
import '../models/media_item.dart';
|
|
import '../services/animation_debugger.dart';
|
|
import '../theme/palette_helper.dart';
|
|
import '../theme/theme_provider.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../services/settings_service.dart';
|
|
import '../services/metadata_service.dart';
|
|
import '../services/debug_logger.dart';
|
|
import '../screens/artist_details_screen.dart';
|
|
import '../screens/album_details_screen.dart';
|
|
import '../utils/page_transitions.dart';
|
|
import 'animated_icon_button.dart';
|
|
import 'global_player_overlay.dart';
|
|
import 'provider_icon.dart';
|
|
import 'volume_control.dart';
|
|
import 'player/player_widgets.dart';
|
|
import 'player/mini_player_content.dart';
|
|
|
|
/// A unified player widget that seamlessly expands from mini to full-screen.
|
|
///
|
|
/// This widget is designed to be used as a global overlay, positioned above
|
|
/// the bottom navigation bar. It uses smooth morphing animations where each
|
|
/// element transitions from their mini to full positions.
|
|
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;
|
|
|
|
/// Whether the device reveal overlay is currently visible
|
|
/// When true, shows player name instead of track name in collapsed state
|
|
final bool isDeviceRevealVisible;
|
|
|
|
/// Whether the hint text should be shown
|
|
/// When true, shows "Pull to select players" instead of track info
|
|
final bool isHintVisible;
|
|
|
|
const ExpandablePlayer({
|
|
super.key,
|
|
this.slideOffset = 0.0,
|
|
this.bounceOffset = 0.0,
|
|
this.onRevealPlayers,
|
|
this.isDeviceRevealVisible = false,
|
|
this.isHintVisible = false,
|
|
});
|
|
|
|
@override
|
|
State<ExpandablePlayer> createState() => ExpandablePlayerState();
|
|
}
|
|
|
|
class ExpandablePlayerState extends State<ExpandablePlayer>
|
|
with TickerProviderStateMixin {
|
|
final DebugLogger _logger = DebugLogger();
|
|
|
|
late AnimationController _controller;
|
|
late Animation<double> _expandAnimation;
|
|
|
|
// Queue panel slide animation
|
|
late AnimationController _queuePanelController;
|
|
late Animation<double> _queuePanelAnimation;
|
|
// Cached slide position animation (avoids recreating Tween.animate every frame)
|
|
late Animation<Offset> _queueSlideAnimation;
|
|
|
|
// Adaptive theme colors extracted from album art
|
|
ColorScheme? _lightColorScheme;
|
|
ColorScheme? _darkColorScheme;
|
|
String? _lastImageUrl;
|
|
|
|
// Queue state
|
|
PlayerQueue? _queue;
|
|
String? _queuePlayerId; // Track which player the queue belongs to
|
|
bool _isLoadingQueue = false;
|
|
bool _isQueueDragging = false; // True while queue item is being dragged
|
|
bool _queuePanelTargetOpen = false; // Target state for queue panel (separate from animation value)
|
|
|
|
// Sleep timer panel animation
|
|
late AnimationController _sleepTimerPanelController;
|
|
late Animation<Offset> _sleepTimerSlideAnimation;
|
|
bool _sleepTimerPanelVisible = false; // Track visibility state explicitly
|
|
bool _sleepTimerPanelTargetOpen = false; // Target state for back gesture handling
|
|
bool _isClosingSleepTimerPanel = false; // Guard against double back gesture
|
|
|
|
// Lyrics panel animation
|
|
late AnimationController _lyricsPanelController;
|
|
late Animation<Offset> _lyricsSlideAnimation;
|
|
bool _lyricsPanelVisible = false;
|
|
bool _lyricsPanelTargetOpen = false; // Target state for back gesture handling
|
|
bool _isClosingLyricsPanel = false; // Guard against double back gesture
|
|
String? _currentLyrics; // Cached lyrics for current track
|
|
bool _isLoadingLyrics = false;
|
|
String? _lyricsTrackId; // Track ID for which lyrics were fetched
|
|
List<_LyricLine>? _parsedLyrics; // Parsed synced lyrics with timestamps
|
|
int _currentLyricIndex = -1; // Currently active lyric line
|
|
final ScrollController _lyricsScrollController = ScrollController();
|
|
StreamSubscription<Duration>? _lyricsPositionSubscription;
|
|
bool _userScrollingLyrics = false; // True when user is manually scrolling
|
|
bool _isLyricsAtTop = true; // Track scroll position for swipe-to-close
|
|
bool _wasLyricsAtTopOnSwipeStart = true; // Capture scroll position when swipe starts
|
|
Offset? _lyricsSwipeStart; // Starting position of swipe gesture
|
|
bool _isSwipingLyricsContent = false; // True during active swipe-to-close gesture
|
|
|
|
// Progress tracking - uses PositionTracker stream as single source of truth
|
|
StreamSubscription<Duration>? _positionSubscription;
|
|
// Queue items subscription - refreshes queue when MA pushes updates (e.g., radio mode adds tracks)
|
|
StreamSubscription<Map<String, dynamic>>? _queueItemsSubscription;
|
|
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
|
|
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
|
|
bool _isSeeking = false; // Debounce flag to prevent rapid-fire seeks
|
|
|
|
// Pre-computed static colors to avoid object creation during animation frames
|
|
static const Color _shadowColor = Color(0x4D000000); // Colors.black.withOpacity(0.3)
|
|
|
|
// PERF Phase 1: Cached SliderThemeData - created once, reused every frame
|
|
static const SliderThemeData _sliderTheme = SliderThemeData(
|
|
trackHeight: 4,
|
|
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
|
|
overlayShape: RoundSliderOverlayShape(overlayRadius: 20),
|
|
trackShape: RoundedRectSliderTrackShape(),
|
|
);
|
|
|
|
// Dimensions
|
|
static double get _collapsedHeight => MiniPlayerLayout.height;
|
|
static const double _collapsedMargin = 12.0; // Increased from 8 to 12 (4px more gap above nav bar)
|
|
static const double _collapsedBorderRadius = 16.0;
|
|
static double get _collapsedArtSize => MiniPlayerLayout.artSize;
|
|
static const double _bottomNavHeight = 56.0;
|
|
static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture
|
|
|
|
// Pastel yellow for grouped players (matches PlayerCard.groupBorderColor)
|
|
static const Color _groupBorderColor = Color(0xFFFFF59D);
|
|
|
|
// Track horizontal drag start position
|
|
double? _horizontalDragStartX;
|
|
|
|
// Track vertical drag start position (for bottom dead zone - Android app switcher)
|
|
double? _verticalDragStartY;
|
|
static const _bottomDeadZone = 80.0; // Dead zone for Android nav gestures
|
|
|
|
// Slide animation for device switching - now supports finger-following
|
|
late AnimationController _slideController;
|
|
// ValueNotifier avoids setState during high-frequency drag updates
|
|
final ValueNotifier<double> _slideOffsetNotifier = ValueNotifier(0.0);
|
|
double get _slideOffset => _slideOffsetNotifier.value;
|
|
set _slideOffset(double value) => _slideOffsetNotifier.value = value;
|
|
bool _isSliding = false;
|
|
bool _isDragging = false; // True while finger is actively dragging
|
|
|
|
// For peek preview - track which player we'd switch to
|
|
dynamic _peekPlayer; // The player that would be selected if swipe commits
|
|
String? _peekImageUrl; // Image URL for peek player's current track
|
|
|
|
// Flag to indicate we're in the middle of a player switch transition
|
|
// When true, we hide main content and show peek content at center
|
|
bool _inTransition = false;
|
|
|
|
// Volume swipe state (only active when device reveal is visible)
|
|
bool _isDraggingVolume = false;
|
|
double _dragVolumeLevel = 0.0;
|
|
int _lastVolumeUpdateTime = 0;
|
|
int _lastVolumeDragEndTime = 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
|
|
|
|
// Volume precision mode state
|
|
bool _inVolumePrecisionMode = false;
|
|
Timer? _volumePrecisionTimer;
|
|
Offset? _lastVolumeDragPosition;
|
|
double _lastVolumeLocalX = 0.0; // Last local X position during drag
|
|
bool _volumePrecisionModeEnabled = true; // From settings
|
|
double _volumePrecisionZoomCenter = 0.0; // Volume level when precision mode started
|
|
double _volumePrecisionStartX = 0.0; // Finger X position when precision mode started
|
|
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; // Zoomed range (10% = left edge to right edge)
|
|
|
|
// Track favorite state for current track
|
|
bool _isCurrentTrackFavorite = false;
|
|
String? _lastTrackUri; // Track which track we last checked favorite status for
|
|
|
|
// Track active ImageStream listeners for proper cleanup on dispose
|
|
final Map<ImageStream, ImageStreamListener> _activeImageStreams = {};
|
|
|
|
// Cached title height to avoid TextPainter.layout() every animation frame
|
|
// Only invalidate when track name changes (screen width doesn't change during animation)
|
|
double? _cachedExpandedTitleHeight;
|
|
String? _lastMeasuredTrackName;
|
|
|
|
// PERF: Cached MaterialRectCenterArcTween for art animation
|
|
// Recreated only when screen dimensions change, not every frame
|
|
MaterialRectCenterArcTween? _artRectTween;
|
|
Size? _lastScreenSize;
|
|
double? _lastTopPadding;
|
|
|
|
// PERF: Pre-cached BoxShadow objects to avoid allocation per frame
|
|
static const BoxShadow _miniPlayerShadow = BoxShadow(
|
|
color: Color(0x4D000000), // 30% black
|
|
blurRadius: 8,
|
|
offset: Offset(0, 2),
|
|
);
|
|
static const BoxShadow _artShadowExpanded = BoxShadow(
|
|
color: Color(0x40000000), // 25% black
|
|
blurRadius: 20,
|
|
offset: Offset(0, 8),
|
|
);
|
|
|
|
// PERF Phase 5: Cached fade animations - created once, reused every frame
|
|
// These replace inline .drive(Tween().chain(CurveTween())) which created new objects per frame
|
|
late Animation<double> _fadeIn50to100; // Fades in during second half of animation
|
|
|
|
// 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();
|
|
|
|
// Reset expansion notifier to match initial controller state (0.0)
|
|
// This fixes a bug where stale progress values from a previous widget instance
|
|
// caused the nav bar to be invisible on app restart/widget recreation
|
|
playerExpansionNotifier.value = PlayerExpansionState(0.0, null, null);
|
|
|
|
_controller = AnimationController(
|
|
duration: const Duration(milliseconds: 300),
|
|
vsync: this,
|
|
);
|
|
|
|
// Simple curved animation - performant and smooth
|
|
_expandAnimation = CurvedAnimation(
|
|
parent: _controller,
|
|
curve: Curves.easeOutCubic,
|
|
reverseCurve: Curves.easeInCubic,
|
|
);
|
|
|
|
// PERF Phase 5: Cache fade animation - avoids creating Tween/CurveTween objects every frame
|
|
// Used for elements that fade in during second half of expansion (t=0.5 to t=1.0)
|
|
_fadeIn50to100 = _expandAnimation.drive(
|
|
Tween<double>(begin: 0.0, end: 1.0).chain(
|
|
CurveTween(curve: const Interval(0.5, 1.0, curve: Curves.easeIn)),
|
|
),
|
|
);
|
|
|
|
// Notify listeners of expansion progress changes
|
|
_controller.addListener(_notifyExpansionProgress);
|
|
|
|
// Animation debugging - record every frame
|
|
_controller.addListener(_recordAnimationFrame);
|
|
|
|
// Queue panel animation - uses spring physics directly (no CurvedAnimation)
|
|
// Spring simulation already provides natural physics-based easing
|
|
// CurvedAnimation would distort the spring output and cause jerky motion during swipe
|
|
_queuePanelController = AnimationController(
|
|
duration: const Duration(milliseconds: 300),
|
|
vsync: this,
|
|
);
|
|
// Use controller directly - spring physics provide the easing
|
|
_queuePanelAnimation = _queuePanelController;
|
|
// Cache the slide animation to avoid recreating Tween.animate() every frame
|
|
// Queue slides up from bottom (0, 1) instead of from right (1, 0)
|
|
_queueSlideAnimation = Tween<Offset>(
|
|
begin: const Offset(0, 1),
|
|
end: Offset.zero,
|
|
).animate(_queuePanelController);
|
|
|
|
// Sleep timer panel animation - uses same spring physics as queue panel
|
|
_sleepTimerPanelController = AnimationController(
|
|
duration: const Duration(milliseconds: 300),
|
|
vsync: this,
|
|
);
|
|
// Ensure value is exactly 0 when dismissed (spring may not settle exactly)
|
|
_sleepTimerPanelController.addStatusListener((status) {
|
|
if (status == AnimationStatus.dismissed && _sleepTimerPanelController.value != 0) {
|
|
_sleepTimerPanelController.value = 0;
|
|
}
|
|
});
|
|
// Use controller directly - spring physics provide the easing (same as queue)
|
|
_sleepTimerSlideAnimation = Tween<Offset>(
|
|
begin: const Offset(0, 1),
|
|
end: Offset.zero,
|
|
).animate(_sleepTimerPanelController);
|
|
|
|
// Lyrics panel animation - same mechanics as sleep timer and queue
|
|
_lyricsPanelController = AnimationController(
|
|
duration: const Duration(milliseconds: 300),
|
|
vsync: this,
|
|
);
|
|
_lyricsSlideAnimation = Tween<Offset>(
|
|
begin: const Offset(0, 1),
|
|
end: Offset.zero,
|
|
).animate(_lyricsPanelController);
|
|
|
|
// Track lyrics scroll position for swipe-to-close (only allowed at top)
|
|
_lyricsScrollController.addListener(_onLyricsScroll);
|
|
|
|
// Slide animation for device switching - used for snap/spring animations
|
|
_slideController = AnimationController(
|
|
duration: const Duration(milliseconds: 250),
|
|
vsync: this,
|
|
);
|
|
|
|
_controller.addStatusListener((status) {
|
|
// PERF: Load queue after animation completes, not during
|
|
// This prevents network I/O from competing with animation frames
|
|
if (status == AnimationStatus.completed) {
|
|
_loadQueue();
|
|
} else if (status == AnimationStatus.dismissed) {
|
|
// Close queue panel when player collapses
|
|
_queuePanelController.duration = _queueCloseDuration;
|
|
_queuePanelController.reverse();
|
|
}
|
|
});
|
|
|
|
// Subscribe to position tracker stream - single source of truth for playback position
|
|
_subscribeToPositionTracker();
|
|
|
|
// Subscribe to queue_items_updated events from Music Assistant
|
|
// This keeps queue in sync when MA pushes changes (e.g., radio mode adds tracks)
|
|
_subscribeToQueueEvents();
|
|
|
|
// Auto-refresh queue when panel is open
|
|
_queuePanelController.addStatusListener((status) {
|
|
if (status == AnimationStatus.completed) {
|
|
_startQueueRefreshTimer();
|
|
} else if (status == AnimationStatus.dismissed) {
|
|
_stopQueueRefreshTimer();
|
|
}
|
|
});
|
|
|
|
// Load precision mode setting
|
|
_loadVolumePrecisionModeSetting();
|
|
}
|
|
|
|
Future<void> _loadVolumePrecisionModeSetting() async {
|
|
final enabled = await SettingsService.getVolumePrecisionMode();
|
|
if (mounted) {
|
|
_volumePrecisionModeEnabled = enabled;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(ExpandablePlayer oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
// Reload precision mode setting when device reveal becomes visible
|
|
// This ensures the setting is fresh after the user may have changed it
|
|
if (widget.isDeviceRevealVisible && !oldWidget.isDeviceRevealVisible) {
|
|
_loadVolumePrecisionModeSetting();
|
|
}
|
|
}
|
|
|
|
void _enterVolumePrecisionMode() {
|
|
if (_inVolumePrecisionMode) return;
|
|
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
|
|
setState(() {
|
|
_inVolumePrecisionMode = true;
|
|
_volumePrecisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
|
|
_volumePrecisionStartX = _lastVolumeLocalX; // Capture finger position at entry
|
|
});
|
|
}
|
|
|
|
void _exitVolumePrecisionMode() {
|
|
_volumePrecisionTimer?.cancel();
|
|
_volumePrecisionTimer = null;
|
|
if (!_inVolumePrecisionMode) return;
|
|
setState(() {
|
|
_inVolumePrecisionMode = false;
|
|
});
|
|
}
|
|
|
|
Timer? _queueRefreshTimer;
|
|
|
|
void _startQueueRefreshTimer() {
|
|
_stopQueueRefreshTimer();
|
|
// Refresh queue at configured interval when panel is open
|
|
_queueRefreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
|
|
if (mounted && isQueuePanelOpen) {
|
|
_loadQueue();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _stopQueueRefreshTimer() {
|
|
_queueRefreshTimer?.cancel();
|
|
_queueRefreshTimer = null;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Explicitly remove listeners before disposing controllers
|
|
// (Controllers remove listeners on dispose, but explicit removal is clearer)
|
|
_controller.removeListener(_notifyExpansionProgress);
|
|
_controller.removeListener(_recordAnimationFrame);
|
|
_lyricsScrollController.removeListener(_onLyricsScroll);
|
|
|
|
_controller.dispose();
|
|
_queuePanelController.dispose();
|
|
_sleepTimerPanelController.dispose();
|
|
_lyricsPanelController.dispose();
|
|
_slideController.dispose();
|
|
_slideOffsetNotifier.dispose();
|
|
_positionSubscription?.cancel();
|
|
_queueItemsSubscription?.cancel();
|
|
_lyricsPositionSubscription?.cancel();
|
|
_lyricsScrollController.dispose();
|
|
_queueRefreshTimer?.cancel();
|
|
_volumePrecisionTimer?.cancel();
|
|
_progressNotifier.dispose();
|
|
_seekPositionNotifier.dispose();
|
|
// Clean up any active ImageStream listeners to prevent memory leaks
|
|
for (final entry in _activeImageStreams.entries) {
|
|
entry.key.removeListener(entry.value);
|
|
}
|
|
_activeImageStreams.clear();
|
|
super.dispose();
|
|
}
|
|
|
|
void _recordAnimationFrame() {
|
|
AnimationDebugger.recordFrame(_controller.value);
|
|
}
|
|
|
|
// Animation durations - asymmetric for snappier collapse
|
|
static const Duration _expandDuration = Duration(milliseconds: 280);
|
|
static const Duration _collapseDuration = Duration(milliseconds: 200);
|
|
static const Duration _queueOpenDuration = Duration(milliseconds: 320);
|
|
static const Duration _queueCloseDuration = Duration(milliseconds: 220);
|
|
|
|
// Spring description for queue panel animations
|
|
// Higher damping ratio prevents oscillation and ensures clean settling
|
|
// Critical damping = 2 * sqrt(stiffness * mass) = 2 * sqrt(550) ≈ 47
|
|
// Using damping slightly above critical for overdamped (no bounce) behavior
|
|
// PERF: Increased stiffness from 400→550 for snappier animation
|
|
static const SpringDescription _queueSpring = SpringDescription(
|
|
mass: 1.0,
|
|
stiffness: 550.0,
|
|
damping: 50.0, // Overdamped - no oscillation, clean settle
|
|
);
|
|
|
|
void expand() {
|
|
if (_isVerticalDragging) return;
|
|
HapticFeedback.lightImpact();
|
|
AnimationDebugger.startSession('playerExpand');
|
|
_controller.duration = _expandDuration;
|
|
_controller.forward().then((_) {
|
|
AnimationDebugger.endSession();
|
|
});
|
|
}
|
|
|
|
void collapse({bool withHaptic = true}) {
|
|
_logger.log('🔙 collapse() called with withHaptic=$withHaptic');
|
|
if (_isVerticalDragging) return;
|
|
// If any panel is still animating closed, don't collapse yet
|
|
// This prevents double back gesture from closing both panel and player
|
|
// Uses animation value (not boolean flag) because animation lags behind flag
|
|
// Note: withHaptic: false prevents double haptic when back gesture is used rapidly
|
|
if (_lyricsPanelController.value > 0.1) {
|
|
_closeLyricsPanel(withHaptic: false);
|
|
return;
|
|
}
|
|
if (_sleepTimerPanelController.value > 0.1) {
|
|
_closeSleepTimerPanel(withHaptic: false);
|
|
return;
|
|
}
|
|
if (isQueuePanelOpen) {
|
|
closeQueuePanel(withHaptic: false);
|
|
return;
|
|
}
|
|
if (withHaptic) {
|
|
_logger.log('🔙 collapse() triggering haptic');
|
|
HapticFeedback.lightImpact();
|
|
}
|
|
AnimationDebugger.startSession('playerCollapse');
|
|
// Instantly hide queue panel when collapsing to avoid visual glitches
|
|
// during Android's predictive back gesture (only reached if queue already closed)
|
|
_queuePanelController.value = 0;
|
|
_queuePanelTargetOpen = false;
|
|
_controller.duration = _collapseDuration;
|
|
_controller.reverse().then((_) {
|
|
AnimationDebugger.endSession();
|
|
// Ensure notifier is reset even if animation was already at 0
|
|
// This fixes a bug where stale notifier values cause invisible nav bar
|
|
if (playerExpansionNotifier.value.progress != 0.0) {
|
|
playerExpansionNotifier.value = PlayerExpansionState(0.0, null, null);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Force collapse player to mini state, instantly closing queue panel if open.
|
|
/// Used for queue transfer where we want to go directly to mini player.
|
|
void forceCollapse() {
|
|
if (_isVerticalDragging) return;
|
|
// Instantly close queue panel without animation
|
|
_queuePanelController.value = 0;
|
|
_queuePanelTargetOpen = false;
|
|
// Collapse player
|
|
_controller.duration = _collapseDuration;
|
|
_controller.reverse().then((_) {
|
|
// Ensure notifier is reset even if animation was already at 0
|
|
if (playerExpansionNotifier.value.progress != 0.0) {
|
|
playerExpansionNotifier.value = PlayerExpansionState(0.0, null, null);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 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) {
|
|
HapticFeedback.lightImpact();
|
|
AnimationDebugger.startSession('playerExpand');
|
|
_controller.duration = _expandDuration;
|
|
_controller.forward().then((_) {
|
|
AnimationDebugger.endSession();
|
|
});
|
|
}
|
|
} else {
|
|
if (currentValue > 0.0) {
|
|
HapticFeedback.lightImpact();
|
|
AnimationDebugger.startSession('playerCollapse');
|
|
_queuePanelController.value = 0;
|
|
_queuePanelTargetOpen = false;
|
|
// Close sleep timer panel when collapsing
|
|
if (_sleepTimerPanelVisible) {
|
|
_sleepTimerPanelVisible = false;
|
|
_sleepTimerPanelTargetOpen = false;
|
|
_sleepTimerPanelController.value = 0;
|
|
}
|
|
// Close lyrics panel when collapsing
|
|
if (_lyricsPanelVisible) {
|
|
_stopLyricsSync();
|
|
_lyricsPanelVisible = false;
|
|
_lyricsPanelTargetOpen = false;
|
|
_lyricsPanelController.value = 0;
|
|
}
|
|
_controller.duration = _collapseDuration;
|
|
_controller.reverse().then((_) {
|
|
AnimationDebugger.endSession();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
bool get isExpanded => _controller.value > 0.5;
|
|
|
|
double get expansionProgress => _controller.value;
|
|
|
|
Color? _currentExpandedBgColor;
|
|
Color? get currentExpandedBgColor => _currentExpandedBgColor;
|
|
Color? _currentExpandedPrimaryColor;
|
|
|
|
// PERF Phase 4: Track last notified value to avoid unnecessary object creation
|
|
double _lastNotifiedProgress = -1;
|
|
|
|
void _notifyExpansionProgress() {
|
|
final progress = _controller.value;
|
|
// PERF Phase 4: Only notify when progress changes by at least 0.01 (1%)
|
|
// This reduces object allocation while maintaining smooth visual transitions
|
|
if ((progress - _lastNotifiedProgress).abs() >= 0.01 ||
|
|
progress == 0.0 || progress == 1.0) {
|
|
_lastNotifiedProgress = progress;
|
|
playerExpansionNotifier.value = PlayerExpansionState(
|
|
progress,
|
|
_currentExpandedBgColor,
|
|
_currentExpandedPrimaryColor,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _subscribeToPositionTracker() {
|
|
_logger.log('📊 _subscribeToPositionTracker called, existing subscription=${_positionSubscription != null}');
|
|
_positionSubscription?.cancel();
|
|
// Subscribe to position tracker stream - single source of truth
|
|
// This eliminates race conditions between multiple timers
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
_logger.log('📊 Subscribing to positionStream for progress bar');
|
|
_positionSubscription = maProvider.positionTracker.positionStream.listen((position) {
|
|
if (!mounted) return;
|
|
_logger.log('📊 Progress received position: ${position.inSeconds}s');
|
|
_progressNotifier.value = position.inSeconds;
|
|
});
|
|
}
|
|
|
|
void _subscribeToQueueEvents() {
|
|
_queueItemsSubscription?.cancel();
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final api = maProvider.api;
|
|
if (api == null) return;
|
|
|
|
// Subscribe to queue_items_updated events from Music Assistant
|
|
// This keeps queue UI in sync when MA adds/removes items (e.g., radio mode)
|
|
_queueItemsSubscription = api.queueItemsUpdatedEvents.listen((event) {
|
|
if (!mounted) return;
|
|
final playerId = event['queue_id'] as String?;
|
|
final selectedPlayer = maProvider.selectedPlayer;
|
|
// Only refresh if the event is for our current player
|
|
if (playerId != null && selectedPlayer != null && playerId == selectedPlayer.playerId) {
|
|
_loadQueue();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _loadQueue() async {
|
|
if (_isLoadingQueue) return;
|
|
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final player = maProvider.selectedPlayer;
|
|
|
|
if (player == null) {
|
|
if (mounted) setState(() => _isLoadingQueue = false);
|
|
return;
|
|
}
|
|
|
|
// Clear queue if player changed (e.g., after queue transfer)
|
|
if (_queuePlayerId != null && _queuePlayerId != player.playerId) {
|
|
_queue = null;
|
|
_queuePlayerId = player.playerId;
|
|
}
|
|
|
|
// 1. Show cached queue immediately (if available)
|
|
// Only load from cache if we don't already have a queue (preserves optimistic updates)
|
|
if (_queue == null) {
|
|
_queuePlayerId = player.playerId;
|
|
final cachedQueue = await maProvider.getCachedQueue(player.playerId);
|
|
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
|
|
setState(() {
|
|
_queue = cachedQueue;
|
|
_isLoadingQueue = false;
|
|
});
|
|
} else {
|
|
setState(() => _isLoadingQueue = true);
|
|
}
|
|
}
|
|
|
|
// 2. Fetch fresh queue in background
|
|
if (maProvider.api != null) {
|
|
try {
|
|
final freshQueue = await maProvider.getQueue(player.playerId);
|
|
if (mounted && freshQueue != null) {
|
|
// Check if queue items were reordered by comparing first few item IDs
|
|
bool itemsReordered = false;
|
|
if (_queue != null && _queue!.items.isNotEmpty && freshQueue.items.isNotEmpty) {
|
|
// Compare first 3 items to detect reordering (shuffle changes item order)
|
|
final maxCheck = (_queue!.items.length < 3 ? _queue!.items.length : 3).clamp(1, freshQueue.items.length);
|
|
for (int i = 0; i < maxCheck; i++) {
|
|
if (_queue!.items[i].queueItemId != freshQueue.items[i].queueItemId) {
|
|
itemsReordered = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Update if queue metadata or item order changed
|
|
final queueChanged = _queue == null ||
|
|
_queue!.items.length != freshQueue.items.length ||
|
|
_queue!.currentIndex != freshQueue.currentIndex ||
|
|
_queue!.shuffle != freshQueue.shuffle ||
|
|
itemsReordered;
|
|
if (queueChanged) {
|
|
setState(() {
|
|
_queue = freshQueue;
|
|
_queuePlayerId = player.playerId;
|
|
_isLoadingQueue = false;
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Silent failure - keep showing cached queue
|
|
}
|
|
}
|
|
|
|
if (mounted && _isLoadingQueue) {
|
|
setState(() => _isLoadingQueue = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _extractColors(String imageUrl) async {
|
|
if (_lastImageUrl == imageUrl) return;
|
|
_lastImageUrl = imageUrl;
|
|
|
|
try {
|
|
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
|
CachedNetworkImageProvider(imageUrl),
|
|
);
|
|
|
|
if (colorSchemes != null && mounted) {
|
|
setState(() {
|
|
_lightColorScheme = colorSchemes.$1;
|
|
_darkColorScheme = colorSchemes.$2;
|
|
});
|
|
|
|
// Share adaptive colors globally via ThemeProvider
|
|
final themeProvider = context.read<ThemeProvider>();
|
|
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2);
|
|
}
|
|
} catch (e) {
|
|
// Silently ignore color extraction errors
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleShuffle() async {
|
|
if (_queue == null) return;
|
|
final newShuffleState = !_queue!.shuffle;
|
|
|
|
// Optimistically update local state for immediate visual feedback
|
|
setState(() {
|
|
_queue = PlayerQueue(
|
|
playerId: _queue!.playerId,
|
|
items: _queue!.items,
|
|
currentIndex: _queue!.currentIndex,
|
|
shuffleEnabled: newShuffleState,
|
|
repeatMode: _queue!.repeatMode,
|
|
);
|
|
});
|
|
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
// Toggle: if currently shuffled, disable; if not shuffled, enable
|
|
await maProvider.toggleShuffle(_queue!.playerId, newShuffleState);
|
|
// Small delay to allow server to finish reordering queue items
|
|
await Future.delayed(const Duration(milliseconds: 150));
|
|
await _loadQueue();
|
|
}
|
|
|
|
Future<void> _cycleRepeat() async {
|
|
if (_queue == null) return;
|
|
|
|
// Calculate next repeat mode: off -> all -> one -> off
|
|
String nextMode;
|
|
switch (_queue!.repeatMode) {
|
|
case 'off':
|
|
case null:
|
|
nextMode = 'all';
|
|
break;
|
|
case 'all':
|
|
nextMode = 'one';
|
|
break;
|
|
case 'one':
|
|
nextMode = 'off';
|
|
break;
|
|
default:
|
|
nextMode = 'off';
|
|
}
|
|
|
|
// Optimistically update local state for immediate visual feedback
|
|
setState(() {
|
|
_queue = PlayerQueue(
|
|
playerId: _queue!.playerId,
|
|
items: _queue!.items,
|
|
currentIndex: _queue!.currentIndex,
|
|
shuffleEnabled: _queue!.shuffleEnabled,
|
|
repeatMode: nextMode,
|
|
);
|
|
});
|
|
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
await maProvider.setRepeatMode(_queue!.playerId, nextMode);
|
|
await _loadQueue();
|
|
}
|
|
|
|
void _toggleQueuePanel() {
|
|
if (_queuePanelController.isAnimating) return;
|
|
// Use threshold check instead of exact equality (spring may not land exactly at 0/1)
|
|
if (_queuePanelController.value < 0.1) {
|
|
_openQueuePanelWithSpring();
|
|
} else {
|
|
_closeQueuePanelWithSpring();
|
|
}
|
|
}
|
|
|
|
/// Open queue panel with spring physics for natural feel
|
|
void _openQueuePanelWithSpring() {
|
|
HapticFeedback.lightImpact();
|
|
// Close sleep timer panel instantly if open
|
|
if (_sleepTimerPanelVisible) {
|
|
_sleepTimerPanelVisible = false;
|
|
_sleepTimerPanelTargetOpen = false;
|
|
_sleepTimerPanelController.value = 0;
|
|
}
|
|
// Close lyrics panel instantly if open
|
|
if (_lyricsPanelVisible) {
|
|
_stopLyricsSync();
|
|
_lyricsPanelVisible = false;
|
|
_lyricsPanelTargetOpen = false;
|
|
_lyricsPanelController.value = 0;
|
|
}
|
|
// setState ensures PopScope rebuilds with correct canPop value
|
|
setState(() {
|
|
_queuePanelTargetOpen = true;
|
|
});
|
|
// Use overdamped spring - settles cleanly without oscillation or snap
|
|
final simulation = SpringSimulation(
|
|
_queueSpring,
|
|
_queuePanelController.value,
|
|
1.0,
|
|
0.0, // velocity
|
|
);
|
|
_queuePanelController.animateWith(simulation);
|
|
}
|
|
|
|
/// Close queue panel with spring physics
|
|
/// [withHaptic]: Set to false for Android back gesture (system provides haptic)
|
|
void _closeQueuePanelWithSpring({double velocity = 0.0, bool withHaptic = true}) {
|
|
// setState ensures PopScope rebuilds with correct canPop value
|
|
setState(() {
|
|
_queuePanelTargetOpen = false;
|
|
});
|
|
if (withHaptic) {
|
|
HapticFeedback.lightImpact();
|
|
}
|
|
// Use overdamped spring for snappy close without oscillation
|
|
// Slightly stiffer than open spring for quicker settle
|
|
// PERF: Increased stiffness from 450→600 for snappier close
|
|
const closeSpring = SpringDescription(
|
|
mass: 1.0,
|
|
stiffness: 600.0,
|
|
damping: 52.0, // Overdamped - no bounce, clean settle
|
|
);
|
|
final simulation = SpringSimulation(
|
|
closeSpring,
|
|
_queuePanelController.value,
|
|
0.0,
|
|
velocity,
|
|
);
|
|
_queuePanelController.animateWith(simulation);
|
|
}
|
|
|
|
bool get isQueuePanelOpen => _queuePanelController.value > 0.5;
|
|
|
|
/// Whether queue panel is intended to be open (target state, not animation value)
|
|
/// Use this for back gesture handling to avoid timing issues during animations
|
|
bool get isQueuePanelTargetOpen => _queuePanelTargetOpen;
|
|
|
|
// Sleep timer panel controls
|
|
bool get isSleepTimerPanelOpen => _sleepTimerPanelVisible;
|
|
|
|
void _toggleSleepTimerPanel() {
|
|
// Don't check isAnimating - allow toggle even during animation
|
|
// The state variable tracks intent, animation will catch up
|
|
if (!_sleepTimerPanelVisible) {
|
|
_openSleepTimerPanel();
|
|
} else {
|
|
_closeSleepTimerPanel();
|
|
}
|
|
}
|
|
|
|
void _openSleepTimerPanel() {
|
|
HapticFeedback.lightImpact();
|
|
// Close queue panel instantly if open
|
|
if (_queuePanelTargetOpen) {
|
|
_queuePanelTargetOpen = false;
|
|
_queuePanelController.value = 0;
|
|
}
|
|
// Close lyrics panel instantly if open
|
|
if (_lyricsPanelTargetOpen) {
|
|
_stopLyricsSync();
|
|
_lyricsPanelTargetOpen = false;
|
|
_lyricsPanelVisible = false;
|
|
_lyricsPanelController.value = 0;
|
|
}
|
|
setState(() {
|
|
_sleepTimerPanelVisible = true;
|
|
_sleepTimerPanelTargetOpen = true;
|
|
});
|
|
// Use same spring physics as queue panel
|
|
final simulation = SpringSimulation(
|
|
_queueSpring,
|
|
_sleepTimerPanelController.value,
|
|
1.0,
|
|
0.0,
|
|
);
|
|
_sleepTimerPanelController.animateWith(simulation);
|
|
}
|
|
|
|
void _closeSleepTimerPanel({double velocity = 0.0, bool withHaptic = true}) {
|
|
// Set guard flag immediately to prevent double back gesture
|
|
_isClosingSleepTimerPanel = true;
|
|
setState(() {
|
|
_sleepTimerPanelVisible = false;
|
|
_sleepTimerPanelTargetOpen = false;
|
|
});
|
|
if (withHaptic) {
|
|
HapticFeedback.lightImpact();
|
|
}
|
|
// Use same spring physics as queue panel close
|
|
const closeSpring = SpringDescription(
|
|
mass: 1.0,
|
|
stiffness: 600.0,
|
|
damping: 52.0,
|
|
);
|
|
final simulation = SpringSimulation(
|
|
closeSpring,
|
|
_sleepTimerPanelController.value,
|
|
0.0,
|
|
velocity,
|
|
);
|
|
_sleepTimerPanelController.animateWith(simulation);
|
|
// Clear guard flag when animation completes
|
|
void onStatus(AnimationStatus status) {
|
|
if (status == AnimationStatus.dismissed) {
|
|
_isClosingSleepTimerPanel = false;
|
|
_sleepTimerPanelController.removeStatusListener(onStatus);
|
|
}
|
|
}
|
|
_sleepTimerPanelController.addStatusListener(onStatus);
|
|
}
|
|
|
|
// Lyrics panel controls
|
|
bool get isLyricsPanelOpen => _lyricsPanelVisible;
|
|
|
|
void _toggleLyricsPanel() {
|
|
if (!_lyricsPanelVisible) {
|
|
_openLyricsPanel();
|
|
} else {
|
|
_closeLyricsPanel();
|
|
}
|
|
}
|
|
|
|
void _openLyricsPanel() {
|
|
HapticFeedback.lightImpact();
|
|
// Close queue panel instantly if open
|
|
if (_queuePanelTargetOpen) {
|
|
_queuePanelTargetOpen = false;
|
|
_queuePanelController.value = 0;
|
|
}
|
|
// Close sleep timer panel if open
|
|
if (_sleepTimerPanelTargetOpen) {
|
|
_sleepTimerPanelTargetOpen = false;
|
|
_sleepTimerPanelVisible = false;
|
|
_sleepTimerPanelController.value = 0;
|
|
}
|
|
setState(() {
|
|
_lyricsPanelVisible = true;
|
|
_lyricsPanelTargetOpen = true;
|
|
});
|
|
// Fetch lyrics for current track if not already loaded
|
|
_fetchLyricsIfNeeded();
|
|
// Start sync if lyrics are already loaded
|
|
if (_hasSyncedLyrics) {
|
|
_startLyricsSync();
|
|
}
|
|
// Use same spring physics as queue panel
|
|
final simulation = SpringSimulation(
|
|
_queueSpring,
|
|
_lyricsPanelController.value,
|
|
1.0,
|
|
0.0,
|
|
);
|
|
_lyricsPanelController.animateWith(simulation);
|
|
}
|
|
|
|
void _closeLyricsPanel({double velocity = 0.0, bool withHaptic = true}) {
|
|
// Set guard flag immediately to prevent double back gesture
|
|
_isClosingLyricsPanel = true;
|
|
// Stop lyrics sync
|
|
_stopLyricsSync();
|
|
setState(() {
|
|
_lyricsPanelVisible = false;
|
|
_lyricsPanelTargetOpen = false;
|
|
});
|
|
if (withHaptic) {
|
|
HapticFeedback.lightImpact();
|
|
}
|
|
const closeSpring = SpringDescription(
|
|
mass: 1.0,
|
|
stiffness: 600.0,
|
|
damping: 52.0,
|
|
);
|
|
final simulation = SpringSimulation(
|
|
closeSpring,
|
|
_lyricsPanelController.value,
|
|
0.0,
|
|
velocity,
|
|
);
|
|
_lyricsPanelController.animateWith(simulation);
|
|
// Clear guard flag when animation completes
|
|
void onStatus(AnimationStatus status) {
|
|
if (status == AnimationStatus.dismissed) {
|
|
_isClosingLyricsPanel = false;
|
|
_lyricsPanelController.removeStatusListener(onStatus);
|
|
}
|
|
}
|
|
_lyricsPanelController.addStatusListener(onStatus);
|
|
}
|
|
|
|
Future<void> _fetchLyricsIfNeeded() async {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final currentTrack = maProvider.currentTrack;
|
|
if (currentTrack == null) return;
|
|
|
|
final trackId = currentTrack.uri ?? currentTrack.itemId;
|
|
|
|
// Skip if already loaded for this track
|
|
if (_lyricsTrackId == trackId && _currentLyrics != null) return;
|
|
|
|
setState(() {
|
|
_isLoadingLyrics = true;
|
|
_currentLyrics = null;
|
|
_parsedLyrics = null;
|
|
_currentLyricIndex = -1;
|
|
});
|
|
|
|
try {
|
|
// Try to fetch lyrics via Music Assistant API first
|
|
String? lyrics = await maProvider.api?.getTrackLyrics(
|
|
currentTrack.toJson(),
|
|
);
|
|
|
|
// If MA didn't return lyrics, try LRCLIB as fallback
|
|
if (lyrics == null || lyrics.isEmpty) {
|
|
final artistName = currentTrack.artists?.firstOrNull?.name ?? '';
|
|
final albumName = currentTrack.album?.name;
|
|
final durationSeconds = currentTrack.duration?.inSeconds;
|
|
|
|
if (artistName.isNotEmpty) {
|
|
final lrclibResult = await MetadataService.getTrackLyrics(
|
|
trackName: currentTrack.name,
|
|
artistName: artistName,
|
|
albumName: albumName,
|
|
durationSeconds: durationSeconds,
|
|
);
|
|
|
|
if (lrclibResult != null) {
|
|
// Prefer synced lyrics, fall back to plain
|
|
final (plainLyrics, syncedLyrics) = lrclibResult;
|
|
lyrics = syncedLyrics ?? plainLyrics;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
final parsed = lyrics != null ? _parseLyrics(lyrics) : null;
|
|
setState(() {
|
|
_currentLyrics = lyrics;
|
|
_parsedLyrics = parsed;
|
|
_lyricsTrackId = trackId;
|
|
_isLoadingLyrics = false;
|
|
_currentLyricIndex = -1;
|
|
});
|
|
// Start syncing if we have synced lyrics
|
|
if (parsed != null) {
|
|
_startLyricsSync();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_currentLyrics = null;
|
|
_parsedLyrics = null;
|
|
_lyricsTrackId = trackId;
|
|
_isLoadingLyrics = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parse LRC formatted lyrics into structured data with timestamps
|
|
/// Returns null if lyrics are not in synced LRC format
|
|
List<_LyricLine>? _parseLyrics(String lyrics) {
|
|
final timestampPattern = RegExp(r'^\[(\d{2}):(\d{2})\.(\d{2,3})\]\s*(.*)$');
|
|
final lines = lyrics.split('\n');
|
|
final parsed = <_LyricLine>[];
|
|
|
|
for (final line in lines) {
|
|
final match = timestampPattern.firstMatch(line.trim());
|
|
if (match != null) {
|
|
final minutes = int.parse(match.group(1)!);
|
|
final seconds = int.parse(match.group(2)!);
|
|
final millisStr = match.group(3)!;
|
|
// Handle both 2-digit (centiseconds) and 3-digit (milliseconds) formats
|
|
final millis = millisStr.length == 2
|
|
? int.parse(millisStr) * 10
|
|
: int.parse(millisStr);
|
|
final text = match.group(4)!.trim();
|
|
|
|
// Skip empty lines (instrumental breaks)
|
|
if (text.isNotEmpty) {
|
|
parsed.add(_LyricLine(
|
|
timestamp: Duration(minutes: minutes, seconds: seconds, milliseconds: millis),
|
|
text: text,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return null if no synced lines found (plain lyrics)
|
|
return parsed.isEmpty ? null : parsed;
|
|
}
|
|
|
|
/// Check if lyrics have sync data
|
|
bool get _hasSyncedLyrics => _parsedLyrics != null && _parsedLyrics!.isNotEmpty;
|
|
|
|
/// Start listening to playback position for lyrics sync
|
|
void _startLyricsSync() {
|
|
_logger.log('🎤 _startLyricsSync called, _hasSyncedLyrics=$_hasSyncedLyrics');
|
|
if (!_hasSyncedLyrics) return;
|
|
_stopLyricsSync(); // Clean up any existing subscription
|
|
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
_logger.log('🎤 Subscribing to positionStream for lyrics');
|
|
_lyricsPositionSubscription = maProvider.positionTracker.positionStream.listen((position) {
|
|
_logger.log('🎤 Lyrics received position: ${position.inSeconds}s, mounted=$mounted, panelVisible=$_lyricsPanelVisible');
|
|
if (!mounted || !_lyricsPanelVisible || _parsedLyrics == null) return;
|
|
_updateCurrentLyricLine(position);
|
|
});
|
|
|
|
// Also update immediately with current position
|
|
final currentPos = maProvider.positionTracker.currentPosition;
|
|
_logger.log('🎤 Lyrics initial position: ${currentPos.inSeconds}s');
|
|
_updateCurrentLyricLine(currentPos);
|
|
}
|
|
|
|
/// Stop lyrics sync subscription
|
|
void _stopLyricsSync() {
|
|
_logger.log('🎤 _stopLyricsSync called, subscription was ${_lyricsPositionSubscription != null ? "active" : "null"}');
|
|
_lyricsPositionSubscription?.cancel();
|
|
_lyricsPositionSubscription = null;
|
|
}
|
|
|
|
/// Track lyrics scroll position for swipe-to-close gesture
|
|
void _onLyricsScroll() {
|
|
final atTop = !_lyricsScrollController.hasClients || _lyricsScrollController.offset <= 0;
|
|
if (atTop != _isLyricsAtTop) {
|
|
setState(() {
|
|
_isLyricsAtTop = atTop;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Check if lyrics panel swipe-to-close should be allowed
|
|
/// Returns true when: no lyrics content, plain lyrics (no scroll tracking), or synced lyrics at top
|
|
bool get _canSwipeCloseLyrics {
|
|
// No lyrics - allow swipe
|
|
if (_currentLyrics == null) return true;
|
|
// Plain lyrics (SingleChildScrollView) - always allow (starts at top)
|
|
if (!_hasSyncedLyrics) return true;
|
|
// Synced lyrics (ListView) - only when at top
|
|
return _isLyricsAtTop;
|
|
}
|
|
|
|
/// Find and highlight the current lyric line based on playback position
|
|
void _updateCurrentLyricLine(Duration position) {
|
|
if (_parsedLyrics == null || _parsedLyrics!.isEmpty) {
|
|
_logger.log('🎤 _updateCurrentLyricLine: No parsed lyrics (null=${_parsedLyrics == null})');
|
|
return;
|
|
}
|
|
|
|
// Find the last line whose timestamp is <= current position
|
|
int newIndex = -1;
|
|
for (int i = 0; i < _parsedLyrics!.length; i++) {
|
|
if (_parsedLyrics![i].timestamp <= position) {
|
|
newIndex = i;
|
|
} else {
|
|
break; // Lines are sorted by timestamp
|
|
}
|
|
}
|
|
|
|
if (newIndex != _currentLyricIndex) {
|
|
_logger.log('🎤 Lyrics index changed: $_currentLyricIndex -> $newIndex at ${position.inSeconds}s');
|
|
setState(() {
|
|
_currentLyricIndex = newIndex;
|
|
});
|
|
|
|
// Auto-scroll to current line if not user scrolling
|
|
if (!_userScrollingLyrics && newIndex >= 0 && _lyricsScrollController.hasClients) {
|
|
_scrollToLyricLine(newIndex);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Scroll to center the given lyric line
|
|
void _scrollToLyricLine(int index) {
|
|
if (!_lyricsScrollController.hasClients) return;
|
|
|
|
// Each line is approximately 48 pixels (fontSize 18 + padding)
|
|
const lineHeight = 52.0;
|
|
final targetOffset = index * lineHeight;
|
|
|
|
// Get the viewport height to center the line
|
|
final viewportHeight = _lyricsScrollController.position.viewportDimension;
|
|
final centeredOffset = (targetOffset - viewportHeight / 2 + lineHeight / 2)
|
|
.clamp(0.0, _lyricsScrollController.position.maxScrollExtent);
|
|
|
|
_lyricsScrollController.animateTo(
|
|
centeredOffset,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
}
|
|
|
|
/// Close queue panel if open (for external access via GlobalPlayerOverlay)
|
|
/// [withHaptic]: Set to false for Android back gesture (system provides haptic)
|
|
void closeQueuePanel({bool withHaptic = true}) {
|
|
// Use target state, not animation value, to handle rapid open-close
|
|
// Allow closing even during animation by stopping it first
|
|
if (_queuePanelTargetOpen) {
|
|
if (_queuePanelController.isAnimating) {
|
|
_queuePanelController.stop();
|
|
}
|
|
_closeQueuePanelWithSpring(withHaptic: withHaptic);
|
|
}
|
|
}
|
|
|
|
/// Update favorite status when track changes
|
|
void _updateFavoriteStatus(dynamic currentTrack) {
|
|
if (currentTrack == null) {
|
|
_isCurrentTrackFavorite = false;
|
|
_lastTrackUri = null;
|
|
return;
|
|
}
|
|
|
|
final trackUri = currentTrack.uri as String?;
|
|
if (trackUri != _lastTrackUri) {
|
|
_lastTrackUri = trackUri;
|
|
_isCurrentTrackFavorite = currentTrack.favorite == true;
|
|
}
|
|
}
|
|
|
|
/// Toggle favorite status for current track (with offline queuing support)
|
|
Future<void> _toggleCurrentTrackFavorite(dynamic currentTrack) async {
|
|
if (currentTrack == null) return;
|
|
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
|
|
try {
|
|
bool success;
|
|
|
|
if (_isCurrentTrackFavorite) {
|
|
// Remove from favorites - need library_item_id (numeric)
|
|
int? libraryItemId;
|
|
|
|
if (currentTrack.provider == 'library') {
|
|
// If provider is library, itemId is the library ID
|
|
libraryItemId = int.tryParse(currentTrack.itemId.toString());
|
|
} else if (currentTrack.providerMappings != null) {
|
|
// Find the library mapping to get the library ID
|
|
final mappings = currentTrack.providerMappings as List<dynamic>?;
|
|
if (mappings != null) {
|
|
for (final mapping in mappings) {
|
|
if (mapping.providerInstance == 'library') {
|
|
libraryItemId = int.tryParse(mapping.itemId.toString());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (libraryItemId != null) {
|
|
success = await maProvider.removeFromFavorites(
|
|
mediaType: 'track',
|
|
libraryItemId: libraryItemId,
|
|
);
|
|
} else {
|
|
throw Exception('Could not determine library ID for this track');
|
|
}
|
|
} else {
|
|
// Add to favorites - use the source provider (not library)
|
|
String actualProvider = currentTrack.provider?.toString() ?? 'library';
|
|
String actualItemId = currentTrack.itemId?.toString() ?? '';
|
|
|
|
if (currentTrack.providerMappings != null) {
|
|
final mappings = currentTrack.providerMappings as List<dynamic>?;
|
|
if (mappings != null && mappings.isNotEmpty) {
|
|
// Find a non-library provider mapping
|
|
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
|
|
for (final mapping in mappings) {
|
|
if (mapping.available == true && mapping.providerInstance != 'library') {
|
|
actualProvider = mapping.providerDomain;
|
|
actualItemId = mapping.itemId;
|
|
break;
|
|
}
|
|
}
|
|
// Fallback to first available if no non-library found
|
|
if (actualProvider == 'library') {
|
|
for (final mapping in mappings) {
|
|
if (mapping.available == true) {
|
|
actualProvider = mapping.providerDomain;
|
|
actualItemId = mapping.itemId;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
success = await maProvider.addToFavorites(
|
|
mediaType: 'track',
|
|
itemId: actualItemId,
|
|
provider: actualProvider,
|
|
);
|
|
}
|
|
|
|
if (success) {
|
|
// Toggle local state
|
|
setState(() {
|
|
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
|
|
});
|
|
|
|
// Invalidate home cache so favorites are updated
|
|
maProvider.invalidateHomeCache();
|
|
|
|
// Show feedback (different message if offline)
|
|
if (mounted) {
|
|
final isOffline = !maProvider.isConnected;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
isOffline
|
|
? S.of(context)!.actionQueuedForSync
|
|
: (_isCurrentTrackFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
|
),
|
|
duration: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Show error feedback
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Navigate to artist details screen
|
|
void _navigateToArtist(MusicAssistantProvider maProvider, dynamic currentTrack) {
|
|
if (currentTrack == null) return;
|
|
|
|
// Skip for podcasts/audiobooks - they show author/podcast name but navigation isn't applicable
|
|
if (maProvider.isPlayingPodcast || maProvider.isPlayingAudiobook) return;
|
|
|
|
// For regular tracks, navigate to first artist
|
|
final artists = currentTrack.artists;
|
|
if (artists != null && artists is List && artists.isNotEmpty) {
|
|
final artist = artists.first;
|
|
if (artist is Artist) {
|
|
// Collapse player first, then navigate using global navigator key
|
|
collapse();
|
|
navigationProvider.navigatorKey.currentState?.push(
|
|
FadeSlidePageRoute(
|
|
child: ArtistDetailsScreen(artist: artist),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Navigate to album details screen
|
|
void _navigateToAlbum(dynamic currentTrack) {
|
|
if (currentTrack == null) return;
|
|
|
|
final album = currentTrack.album;
|
|
if (album != null && album is Album) {
|
|
// Collapse player first, then navigate using global navigator key
|
|
collapse();
|
|
navigationProvider.navigatorKey.currentState?.push(
|
|
FadeSlidePageRoute(
|
|
child: AlbumDetailsScreen(album: album),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Show fullscreen album art overlay
|
|
void _showFullscreenArt(BuildContext context, String? imageUrl) {
|
|
if (imageUrl == null) return;
|
|
|
|
Navigator.of(context).push(
|
|
PageRouteBuilder(
|
|
opaque: false,
|
|
barrierColor: Colors.black87,
|
|
barrierDismissible: true,
|
|
pageBuilder: (context, animation, secondaryAnimation) {
|
|
return FadeTransition(
|
|
opacity: animation,
|
|
child: GestureDetector(
|
|
onTap: () => Navigator.of(context).pop(),
|
|
onVerticalDragEnd: (details) {
|
|
if (details.primaryVelocity != null && details.primaryVelocity!.abs() > 300) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
body: Center(
|
|
child: Hero(
|
|
tag: 'fullscreen_album_art',
|
|
child: InteractiveViewer(
|
|
minScale: 0.5,
|
|
maxScale: 3.0,
|
|
child: CachedNetworkImage(
|
|
imageUrl: imageUrl,
|
|
fit: BoxFit.contain,
|
|
memCacheWidth: 1024,
|
|
memCacheHeight: 1024,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Get available players sorted alphabetically (consistent with device selector)
|
|
List<dynamic> _getAvailablePlayersSorted(MusicAssistantProvider maProvider) {
|
|
// Use the provider's already-sorted list, filter for available only
|
|
return maProvider.availablePlayers.where((p) => p.available).toList();
|
|
}
|
|
|
|
/// Get the next or previous player relative to current selection
|
|
dynamic _getAdjacentPlayer(MusicAssistantProvider maProvider, {required bool next}) {
|
|
final players = _getAvailablePlayersSorted(maProvider);
|
|
if (players.length <= 1) return null;
|
|
|
|
final selectedPlayerId = maProvider.selectedPlayer?.playerId;
|
|
final currentIndex = players.indexWhere((p) => p.playerId == selectedPlayerId);
|
|
|
|
if (currentIndex == -1) return players[0];
|
|
|
|
int adjacentIndex;
|
|
if (next) {
|
|
adjacentIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
|
|
} else {
|
|
adjacentIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
|
|
}
|
|
|
|
return players[adjacentIndex];
|
|
}
|
|
|
|
// Cache for peek track data
|
|
dynamic _peekTrack;
|
|
|
|
/// Cycle to the next available player (for swipe gesture)
|
|
void _cycleToNextPlayer(MusicAssistantProvider maProvider, {bool reverse = false}) {
|
|
final players = _getAvailablePlayersSorted(maProvider);
|
|
if (players.length <= 1) return;
|
|
|
|
final selectedPlayerId = maProvider.selectedPlayer?.playerId;
|
|
final currentIndex = players.indexWhere((p) => p.playerId == selectedPlayerId);
|
|
|
|
// If current player not found in list, start from beginning
|
|
if (currentIndex == -1) {
|
|
HapticFeedback.mediumImpact();
|
|
maProvider.selectPlayer(players[0]);
|
|
return;
|
|
}
|
|
|
|
int nextIndex;
|
|
if (reverse) {
|
|
nextIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
|
|
} else {
|
|
nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
|
|
}
|
|
|
|
// Haptic feedback on device switch
|
|
HapticFeedback.mediumImpact();
|
|
|
|
// Animate slide transition
|
|
_animateSlide(reverse ? 1 : -1, () {
|
|
maProvider.selectPlayer(players[nextIndex]);
|
|
});
|
|
}
|
|
|
|
/// Handle real-time drag updates for finger-following swipe
|
|
void _handleHorizontalDragUpdate(DragUpdateDetails details, MusicAssistantProvider maProvider, double containerWidth) {
|
|
if (_isSliding) return;
|
|
|
|
// Start tracking if not already
|
|
if (!_isDragging) {
|
|
_isDragging = true;
|
|
_peekPlayer = null;
|
|
_peekImageUrl = null;
|
|
_peekTrack = null;
|
|
}
|
|
|
|
// Calculate normalized offset (-1 to 1 range)
|
|
// Negative = dragging left (showing next player from right)
|
|
// Positive = dragging right (showing previous player from left)
|
|
final delta = details.primaryDelta ?? 0;
|
|
final normalizedDelta = delta / containerWidth;
|
|
final newSlideOffset = (_slideOffset + normalizedDelta).clamp(-1.0, 1.0);
|
|
|
|
// Update slideOffset via ValueNotifier (no setState needed - avoids 60fps rebuilds)
|
|
_slideOffset = newSlideOffset;
|
|
|
|
// Only trigger setState when peek player changes (not every frame)
|
|
if (_slideOffset != 0) {
|
|
_updatePeekPlayerState(maProvider, _slideOffset);
|
|
}
|
|
}
|
|
|
|
/// Update peek player state variables - only calls setState when peek player changes
|
|
void _updatePeekPlayerState(MusicAssistantProvider maProvider, double dragDirection) {
|
|
// dragDirection < 0 means swiping left (next player)
|
|
// dragDirection > 0 means swiping right (previous player)
|
|
final isNext = dragDirection < 0;
|
|
final newPeekPlayer = _getAdjacentPlayer(maProvider, next: isNext);
|
|
|
|
// Only setState when peek player actually changes (prevents unnecessary rebuilds)
|
|
if (newPeekPlayer?.playerId != _peekPlayer?.playerId) {
|
|
setState(() {
|
|
_peekPlayer = newPeekPlayer;
|
|
// Get the peek player's current track image if available
|
|
if (_peekPlayer != null) {
|
|
_peekTrack = maProvider.getCachedTrackForPlayer(_peekPlayer.playerId);
|
|
_peekImageUrl = _peekTrack != null ? maProvider.getImageUrl(_peekTrack, size: 512) : null;
|
|
} else {
|
|
_peekTrack = null;
|
|
_peekImageUrl = null;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Handle drag end - either commit to next player or snap back
|
|
void _handleHorizontalDragEnd(DragEndDetails details, MusicAssistantProvider maProvider) {
|
|
if (!_isDragging || _isSliding) {
|
|
_isDragging = false;
|
|
return;
|
|
}
|
|
|
|
_isDragging = false;
|
|
final velocity = details.primaryVelocity ?? 0;
|
|
|
|
// Thresholds for committing the swipe
|
|
const commitThreshold = 0.3; // 30% of width
|
|
const velocityThreshold = 500.0; // px/s
|
|
|
|
final shouldCommit = _slideOffset.abs() > commitThreshold || velocity.abs() > velocityThreshold;
|
|
final direction = _slideOffset != 0 ? _slideOffset.sign : (velocity != 0 ? -velocity.sign : 0);
|
|
|
|
if (shouldCommit && _peekPlayer != null && direction != 0) {
|
|
// Commit: animate to full slide, switch player, then reset
|
|
// Note: Don't clear _peekPlayer here - let _animateCommit handle cleanup
|
|
// so the peek content can remain visible during the transition
|
|
_animateCommit(direction.toInt(), () {
|
|
HapticFeedback.mediumImpact();
|
|
maProvider.selectPlayer(_peekPlayer);
|
|
});
|
|
} else {
|
|
// Cancel: spring back to center
|
|
_animateSnapBack();
|
|
}
|
|
}
|
|
|
|
/// Animate committing to the next/previous player
|
|
void _animateCommit(int direction, VoidCallback onSwitch) {
|
|
if (_isSliding) return;
|
|
|
|
// Mark transition state in setState to ensure rebuilds happen properly
|
|
setState(() {
|
|
_isSliding = true;
|
|
// Mark transition BEFORE animation - this keeps peek content visible
|
|
// and hides main content to prevent any flash
|
|
_inTransition = true;
|
|
});
|
|
|
|
final startOffset = _slideOffset;
|
|
final targetOffset = direction < 0 ? -1.0 : 1.0;
|
|
|
|
_slideController.reset();
|
|
|
|
void animateToTarget() {
|
|
if (!mounted) return;
|
|
final curvedValue = Curves.easeOutCubic.transform(_slideController.value);
|
|
// ValueNotifier triggers AnimatedBuilder rebuild - no setState needed
|
|
_slideOffset = startOffset + (targetOffset - startOffset) * curvedValue;
|
|
}
|
|
|
|
_slideController.addListener(animateToTarget);
|
|
_slideController.duration = const Duration(milliseconds: 150);
|
|
|
|
_slideController.forward().then((_) {
|
|
if (!mounted) return;
|
|
|
|
_slideController.removeListener(animateToTarget);
|
|
|
|
// Cache the peek data BEFORE switching - we'll use this to crossfade
|
|
final cachedPeekPlayer = _peekPlayer;
|
|
final cachedPeekTrack = _peekTrack;
|
|
final cachedPeekImageUrl = _peekImageUrl;
|
|
|
|
// Switch the player - this sets currentTrack from cache in the provider
|
|
onSwitch();
|
|
|
|
// Get the new track state
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final newTrack = maProvider.currentTrack;
|
|
|
|
// Move peek content to center position (slideOffset = 0) while keeping it visible.
|
|
// The main content is hidden via _inTransition flag.
|
|
// This creates a seamless visual - peek content is already at center.
|
|
_slideOffset = 0.0; // ValueNotifier triggers AnimatedBuilder rebuild
|
|
setState(() {
|
|
_isSliding = false;
|
|
// Keep _inTransition = true and peek data intact
|
|
});
|
|
|
|
if (newTrack != null) {
|
|
// Switching to a playing player - need to wait for image to be ready
|
|
// before showing main content and hiding peek
|
|
final newImageUrl = maProvider.getImageUrl(newTrack, size: 512);
|
|
|
|
if (newImageUrl != null && newImageUrl == cachedPeekImageUrl) {
|
|
// Same image - can transition immediately
|
|
_completeTransition();
|
|
} else if (newImageUrl != null) {
|
|
// Different image - precache it first, then transition
|
|
_precacheAndTransition(newImageUrl);
|
|
} else {
|
|
// No image - transition after frame
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _completeTransition();
|
|
});
|
|
}
|
|
} else {
|
|
// Switching to non-playing player - DeviceSelectorBar will show
|
|
// Wait for it to render, then complete transition
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _completeTransition();
|
|
});
|
|
});
|
|
}
|
|
|
|
_slideController.duration = const Duration(milliseconds: 250); // Reset default
|
|
});
|
|
}
|
|
|
|
/// Precache an image and then complete the transition
|
|
void _precacheAndTransition(String imageUrl) {
|
|
// Use CachedNetworkImage's cache to ensure image is ready
|
|
final imageProvider = CachedNetworkImageProvider(imageUrl);
|
|
final imageStream = imageProvider.resolve(const ImageConfiguration());
|
|
late ImageStreamListener listener;
|
|
|
|
void cleanupListener() {
|
|
imageStream.removeListener(listener);
|
|
_activeImageStreams.remove(imageStream);
|
|
}
|
|
|
|
listener = ImageStreamListener(
|
|
(ImageInfo info, bool synchronousCall) {
|
|
cleanupListener();
|
|
if (mounted) {
|
|
// Image is now in memory cache - safe to transition
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _completeTransition();
|
|
});
|
|
}
|
|
},
|
|
onError: (exception, stackTrace) {
|
|
cleanupListener();
|
|
// Even on error, complete the transition
|
|
if (mounted) _completeTransition();
|
|
},
|
|
);
|
|
|
|
// Track the listener for cleanup on dispose
|
|
_activeImageStreams[imageStream] = listener;
|
|
imageStream.addListener(listener);
|
|
|
|
// Safety timeout - don't wait forever
|
|
Future.delayed(const Duration(milliseconds: 500), () {
|
|
if (mounted && _inTransition && _activeImageStreams.containsKey(imageStream)) {
|
|
cleanupListener();
|
|
_completeTransition();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Complete the transition by hiding peek content and showing main content
|
|
void _completeTransition() {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_inTransition = false;
|
|
_peekPlayer = null;
|
|
_peekTrack = null;
|
|
_peekImageUrl = null;
|
|
});
|
|
}
|
|
|
|
/// Animate snapping back to center (cancelled swipe)
|
|
void _animateSnapBack() {
|
|
if (_isSliding) return;
|
|
_isSliding = true;
|
|
|
|
final startOffset = _slideOffset;
|
|
_slideController.reset();
|
|
|
|
void animateBack() {
|
|
if (!mounted) return;
|
|
final curvedValue = Curves.easeOutBack.transform(_slideController.value);
|
|
// ValueNotifier triggers AnimatedBuilder rebuild - no setState needed
|
|
_slideOffset = startOffset * (1.0 - curvedValue);
|
|
}
|
|
|
|
_slideController.addListener(animateBack);
|
|
_slideController.duration = const Duration(milliseconds: 220); // Snappier snap-back
|
|
|
|
_slideController.forward().then((_) {
|
|
if (!mounted) return;
|
|
_slideController.removeListener(animateBack);
|
|
// Update slideOffset via ValueNotifier (triggers rebuild)
|
|
_slideOffset = 0.0;
|
|
// Other state changes still need setState
|
|
setState(() {
|
|
_isSliding = false;
|
|
_peekPlayer = null;
|
|
_peekImageUrl = null;
|
|
});
|
|
_slideController.duration = const Duration(milliseconds: 250); // Reset default
|
|
});
|
|
}
|
|
|
|
/// Legacy method - now calls new commit animation
|
|
void _animateSlide(int direction, VoidCallback onComplete) {
|
|
_animateCommit(direction, onComplete);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// PERF: Use select() to only rebuild when adaptive theme setting changes
|
|
final adaptiveTheme = context.select<ThemeProvider, bool>((p) => p.adaptiveTheme);
|
|
// Get full themeProvider for methods that need adaptive color schemes
|
|
final themeProvider = context.read<ThemeProvider>();
|
|
|
|
return Consumer<MusicAssistantProvider>(
|
|
builder: (context, maProvider, child) {
|
|
final selectedPlayer = maProvider.selectedPlayer;
|
|
final currentTrack = maProvider.currentTrack;
|
|
|
|
// Don't show if no player selected
|
|
if (selectedPlayer == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
// Get image URL for current track
|
|
final imageUrl = currentTrack != null
|
|
? maProvider.getImageUrl(currentTrack, size: 512)
|
|
: null;
|
|
|
|
// Extract colors for adaptive theme
|
|
if (adaptiveTheme && imageUrl != null) {
|
|
_extractColors(imageUrl);
|
|
}
|
|
|
|
// Update favorite status when track changes
|
|
_updateFavoriteStatus(currentTrack);
|
|
|
|
// Sync progress notifier with position tracker
|
|
// This ensures the progress bar shows correct position on expand,
|
|
// after seek, or when switching players
|
|
final currentPosition = maProvider.positionTracker.currentPosition.inSeconds;
|
|
if (_progressNotifier.value != currentPosition) {
|
|
// Use addPostFrameCallback to avoid updating during build
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_progressNotifier.value = currentPosition;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Handle Android back button - close panels first, then collapse player
|
|
// Priority: queue panel > lyrics panel > sleep timer panel > collapse player
|
|
// Use target states (intent) instead of animation values for reliable back handling
|
|
// This prevents race conditions where state changes during gesture processing
|
|
return PopScope(
|
|
canPop: !_queuePanelTargetOpen && !_lyricsPanelTargetOpen && !_sleepTimerPanelTargetOpen && !isExpanded,
|
|
onPopInvokedWithResult: (didPop, result) {
|
|
_logger.log('🔙 PopScope onPopInvokedWithResult: didPop=$didPop, isExpanded=$isExpanded, isAnimating=${_controller.isAnimating}');
|
|
if (!didPop) {
|
|
// Ignore back events while a panel is closing
|
|
// Guard flags are set immediately when close starts, before animation
|
|
// This prevents double back gesture from closing both panel and player
|
|
if (_isClosingLyricsPanel || _isClosingSleepTimerPanel) {
|
|
_logger.log('🔙 PopScope: Ignoring - panel is closing');
|
|
return;
|
|
}
|
|
|
|
if (_queuePanelTargetOpen) {
|
|
// Always close queue panel on back, even if animating
|
|
// Stop any existing animation and start close
|
|
if (_queuePanelController.isAnimating) {
|
|
_queuePanelController.stop();
|
|
}
|
|
// No haptic - Android back gesture provides its own haptic feedback
|
|
_closeQueuePanelWithSpring(withHaptic: false);
|
|
} else if (_lyricsPanelTargetOpen) {
|
|
// Close lyrics panel (no haptic - Android provides its own)
|
|
_closeLyricsPanel(withHaptic: false);
|
|
} else if (_sleepTimerPanelTargetOpen) {
|
|
// Close sleep timer panel (no haptic - Android provides its own)
|
|
_closeSleepTimerPanel(withHaptic: false);
|
|
} else if (isExpanded) {
|
|
// Only collapse if not already animating
|
|
// No haptic - Android back gesture provides its own
|
|
if (!_controller.isAnimating) {
|
|
collapse(withHaptic: false);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
child: AnimatedBuilder(
|
|
// PERF: Only include _expandAnimation and _slideOffsetNotifier
|
|
// Queue panel has its own AnimatedBuilder - don't rebuild entire player on queue animation
|
|
animation: Listenable.merge([_expandAnimation, _slideOffsetNotifier]),
|
|
builder: (context, _) {
|
|
// If no track is playing, show device selector bar
|
|
if (currentTrack == null) {
|
|
return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider);
|
|
}
|
|
return _buildMorphingPlayer(
|
|
context,
|
|
maProvider,
|
|
selectedPlayer,
|
|
currentTrack,
|
|
imageUrl,
|
|
themeProvider,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Build a compact device selector bar when no track is playing
|
|
Widget _buildDeviceSelectorBar(
|
|
BuildContext context,
|
|
MusicAssistantProvider maProvider,
|
|
dynamic selectedPlayer,
|
|
ThemeProvider themeProvider,
|
|
) {
|
|
// PERF Phase 1: Batch MediaQuery and Theme lookups
|
|
final mediaQuery = MediaQuery.of(context);
|
|
final screenSize = mediaQuery.size;
|
|
// Use viewPadding to match BottomNavigationBar's height calculation
|
|
final bottomPadding = mediaQuery.viewPadding.bottom;
|
|
final theme = Theme.of(context);
|
|
final colorScheme = theme.colorScheme;
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
|
|
// Get adaptive colors if available
|
|
final adaptiveScheme = themeProvider.adaptiveTheme
|
|
? (isDark ? _darkColorScheme : _lightColorScheme)
|
|
: null;
|
|
|
|
final backgroundColor = themeProvider.adaptiveTheme && adaptiveScheme != null
|
|
? adaptiveScheme.primaryContainer
|
|
: colorScheme.primaryContainer;
|
|
final textColor = themeProvider.adaptiveTheme && adaptiveScheme != null
|
|
? adaptiveScheme.onPrimaryContainer
|
|
: colorScheme.onPrimaryContainer;
|
|
|
|
final bottomNavSpace = _bottomNavHeight + bottomPadding;
|
|
final bottomOffset = bottomNavSpace + _collapsedMargin;
|
|
final width = screenSize.width - (_collapsedMargin * 2);
|
|
|
|
// Apply slide offset for hiding and bounce offset for reveal animation
|
|
final slideDownAmount = widget.slideOffset * (_collapsedHeight + bottomOffset + 20);
|
|
final bounceDownAmount = widget.bounceOffset;
|
|
final adjustedBottomOffset = bottomOffset - slideDownAmount - bounceDownAmount;
|
|
|
|
final availablePlayers = _getAvailablePlayersSorted(maProvider);
|
|
final hasMultiplePlayers = availablePlayers.length > 1;
|
|
|
|
return Positioned(
|
|
left: _collapsedMargin,
|
|
right: _collapsedMargin,
|
|
bottom: adjustedBottomOffset,
|
|
child: GestureDetector(
|
|
// Tap to dismiss player reveal (matching _buildMorphingPlayer behavior)
|
|
onTap: widget.isDeviceRevealVisible ? GlobalPlayerOverlay.dismissPlayerReveal : null,
|
|
onVerticalDragUpdate: (details) {
|
|
if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) {
|
|
widget.onRevealPlayers!();
|
|
}
|
|
},
|
|
child: DeviceSelectorBar(
|
|
selectedPlayer: selectedPlayer,
|
|
peekPlayer: _peekPlayer,
|
|
hasMultiplePlayers: hasMultiplePlayers,
|
|
backgroundColor: backgroundColor,
|
|
textColor: textColor,
|
|
width: width,
|
|
height: _collapsedHeight,
|
|
borderRadius: _collapsedBorderRadius,
|
|
slideOffset: _slideOffset,
|
|
onHorizontalDragStart: (details) {
|
|
_horizontalDragStartX = details.globalPosition.dx;
|
|
},
|
|
onHorizontalDragUpdate: (details) {
|
|
// Check for edge dead zone
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
final startedInDeadZone = _horizontalDragStartX != null &&
|
|
(_horizontalDragStartX! > screenWidth - _edgeDeadZone ||
|
|
_horizontalDragStartX! < _edgeDeadZone);
|
|
if (startedInDeadZone) return;
|
|
|
|
_handleHorizontalDragUpdate(details, maProvider, width);
|
|
},
|
|
onHorizontalDragEnd: (details) {
|
|
// Check for edge dead zone
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
final startedInDeadZone = _horizontalDragStartX != null &&
|
|
(_horizontalDragStartX! > screenWidth - _edgeDeadZone ||
|
|
_horizontalDragStartX! < _edgeDeadZone);
|
|
_horizontalDragStartX = null;
|
|
|
|
if (startedInDeadZone) return;
|
|
|
|
_handleHorizontalDragEnd(details, maProvider);
|
|
},
|
|
onPowerToggle: () => maProvider.togglePower(selectedPlayer.playerId),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMorphingPlayer(
|
|
BuildContext context,
|
|
MusicAssistantProvider maProvider,
|
|
dynamic selectedPlayer,
|
|
dynamic currentTrack,
|
|
String? imageUrl,
|
|
ThemeProvider themeProvider,
|
|
) {
|
|
// PERF Phase 1: Batch MediaQuery lookups - single InheritedWidget lookup
|
|
final mediaQuery = MediaQuery.of(context);
|
|
final screenSize = mediaQuery.size;
|
|
// Use viewPadding (not padding) to match BottomNavigationBar's height calculation
|
|
// viewPadding represents permanent system UI, padding can change (e.g., keyboard)
|
|
final bottomPadding = mediaQuery.viewPadding.bottom;
|
|
final topPadding = mediaQuery.padding.top;
|
|
// PERF Phase 1: Batch Theme lookups
|
|
final theme = Theme.of(context);
|
|
final colorScheme = theme.colorScheme;
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
|
|
// Animation progress
|
|
final t = _expandAnimation.value;
|
|
|
|
// Get adaptive colors if available
|
|
final adaptiveScheme = themeProvider.adaptiveTheme
|
|
? (isDark ? _darkColorScheme : _lightColorScheme)
|
|
: null;
|
|
|
|
// Color transitions - mini player uses adaptive primaryContainer (darker tinted color)
|
|
final collapsedBg = themeProvider.adaptiveTheme && adaptiveScheme != null
|
|
? adaptiveScheme.primaryContainer
|
|
: colorScheme.primaryContainer;
|
|
// Create a darker shade for the "unplayed" portion of progress bar
|
|
final collapsedBgUnplayed = Color.lerp(collapsedBg, Colors.black, 0.3)!;
|
|
final expandedBg = adaptiveScheme?.surface ?? const Color(0xFF121212);
|
|
final expandedPrimary = adaptiveScheme?.primary;
|
|
// Always update to current value - don't preserve stale adaptive colors
|
|
_currentExpandedBgColor = expandedBg;
|
|
_currentExpandedPrimaryColor = expandedPrimary;
|
|
// When collapsed, use the darker unplayed color as base (progress bar will overlay the played portion)
|
|
// When expanded, transition to the normal background
|
|
final backgroundColor = Color.lerp(t < 0.5 ? collapsedBgUnplayed : collapsedBg, expandedBg, t)!;
|
|
|
|
// Update system nav bar to match player background when expanding
|
|
// Use post-frame callback to avoid setting state during build
|
|
if (t > 0.5) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
final iconBrightness = backgroundColor.computeLuminance() > 0.5
|
|
? Brightness.dark
|
|
: Brightness.light;
|
|
SystemChrome.setSystemUIOverlayStyle(
|
|
SystemUiOverlayStyle(
|
|
systemNavigationBarColor: backgroundColor,
|
|
systemNavigationBarIconBrightness: iconBrightness,
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
final collapsedTextColor = themeProvider.adaptiveTheme && adaptiveScheme != null
|
|
? adaptiveScheme.onPrimaryContainer
|
|
: colorScheme.onPrimaryContainer;
|
|
// Use colorScheme.onSurface as fallback instead of Colors.white for light theme support
|
|
final expandedTextColor = adaptiveScheme?.onSurface ?? colorScheme.onSurface;
|
|
final textColor = Color.lerp(collapsedTextColor, expandedTextColor, t)!;
|
|
|
|
// Use colorScheme.primary as fallback instead of Colors.white for light theme support
|
|
final primaryColor = adaptiveScheme?.primary ?? colorScheme.primary;
|
|
|
|
// PERF Phase 4: Pre-compute commonly used color opacities to reduce withOpacity() allocations per frame
|
|
// These are used multiple times throughout the widget tree during animation
|
|
final textColor50 = textColor.withOpacity(0.5);
|
|
final textColor60 = textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity);
|
|
final textColor70 = textColor.withOpacity(0.7);
|
|
final textColor45 = textColor.withOpacity(0.45);
|
|
final primaryColor20 = primaryColor.withOpacity(0.2);
|
|
final primaryColor70 = primaryColor.withOpacity(0.7);
|
|
|
|
// PERF Phase 5: Pre-compute Alignment and FontWeight to avoid lerp calls per text element
|
|
// These are used by title, artist, and player name text elements
|
|
final textAlignment = Alignment.lerp(Alignment.centerLeft, Alignment.center, t)!;
|
|
final titleFontWeight = FontWeight.lerp(MiniPlayerLayout.primaryFontWeight, FontWeight.w600, t);
|
|
|
|
// Collapsed: position above bottom nav bar
|
|
// Expanded: extend to very bottom (behind system nav bar) for seamless edge-to-edge
|
|
final bottomNavSpace = _bottomNavHeight + bottomPadding;
|
|
final collapsedBottomOffset = bottomNavSpace + _collapsedMargin;
|
|
final expandedBottomOffset = 0.0; // Extend behind system nav bar
|
|
final expandedHeight = screenSize.height; // Full screen height for edge-to-edge
|
|
|
|
// Apply slide offset to hide mini player (slides down off-screen)
|
|
// 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 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);
|
|
final height = _lerpDouble(_collapsedHeight, expandedHeight, t);
|
|
final horizontalMargin = _lerpDouble(_collapsedMargin, 0, t);
|
|
// Use slide-adjusted offset when collapsed, normal lerp otherwise
|
|
final bottomOffset = slideAdjustedBottomOffset;
|
|
final borderRadius = _lerpDouble(_collapsedBorderRadius, 0, t);
|
|
|
|
// ===========================================
|
|
// EXPANDED LAYOUT - Vertical rhythm based design
|
|
// ===========================================
|
|
// Using 8px grid for consistent spacing
|
|
// Header: 48px (player name area)
|
|
// Art: proportional to screen, centered
|
|
// Track info: clear hierarchy with breathing room
|
|
// Progress: slim, elegant
|
|
// Controls: generously spaced
|
|
// Volume: bottom anchored
|
|
|
|
final headerHeight = 48.0;
|
|
final contentPadding = 32.0; // horizontal padding for content
|
|
|
|
// Art sizing - larger on bigger screens, max 85% of width
|
|
final maxArtSize = screenSize.width - (contentPadding * 2);
|
|
final expandedArtSize = (maxArtSize * 0.92).clamp(280.0, 400.0);
|
|
final artBorderRadius = _lerpDouble(0, 12, t); // Square in mini player, rounded when expanded
|
|
|
|
// Art position - uses MaterialRectCenterArcTween for Hero-like curved arc path
|
|
// This creates a natural arc trajectory instead of straight diagonal movement
|
|
// PERF: Cache the tween - only recreate when screen dimensions change
|
|
final collapsedArtLeft = 0.0;
|
|
final collapsedArtTop = (_collapsedHeight - _collapsedArtSize) / 2;
|
|
final expandedArtLeft = (screenSize.width - expandedArtSize) / 2;
|
|
final expandedArtTop = topPadding + headerHeight + 16;
|
|
|
|
if (_artRectTween == null || _lastScreenSize != screenSize || _lastTopPadding != topPadding) {
|
|
final collapsedArtRect = Rect.fromLTWH(collapsedArtLeft, collapsedArtTop, _collapsedArtSize, _collapsedArtSize);
|
|
final expandedArtRect = Rect.fromLTWH(expandedArtLeft, expandedArtTop, expandedArtSize, expandedArtSize);
|
|
_artRectTween = MaterialRectCenterArcTween(begin: collapsedArtRect, end: expandedArtRect);
|
|
_lastScreenSize = screenSize;
|
|
_lastTopPadding = topPadding;
|
|
}
|
|
final artRect = _artRectTween!.lerp(t)!;
|
|
final artLeft = artRect.left;
|
|
final artTop = artRect.top;
|
|
final artSize = artRect.width;
|
|
|
|
// Typography - uses shared MiniPlayerLayout constants for collapsed state
|
|
final titleFontSize = _lerpDouble(MiniPlayerLayout.primaryFontSize, 24.0, t);
|
|
final artistFontSize = _lerpDouble(MiniPlayerLayout.secondaryFontSize, 18.0, t);
|
|
|
|
// Text position - left edge position (alignment handles centering smoothly)
|
|
final expandedTitleLeft = contentPadding;
|
|
final titleLeft = _lerpDouble(MiniPlayerLayout.textLeft, expandedTitleLeft, t);
|
|
|
|
final collapsedTitleTop = MiniPlayerLayout.primaryTop;
|
|
|
|
// Controls: 36 (prev) + 34 (play) + 36 (next) + 8 (right margin) = 114px from widget right
|
|
// For 8px gap: text ends at widgetWidth - 114 - 8 = widgetWidth - 122
|
|
final collapsedTitleWidth = collapsedWidth - MiniPlayerLayout.textLeft - 122;
|
|
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
|
|
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
|
|
|
|
// Measure actual title height for dynamic layout (CACHED to avoid layout every frame)
|
|
final titleStyle = TextStyle(
|
|
fontSize: 24.0,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: -0.5,
|
|
height: 1.2,
|
|
);
|
|
// Only recalculate when track changes (screen width doesn't change during animation)
|
|
if (_lastMeasuredTrackName != currentTrack.name ||
|
|
_cachedExpandedTitleHeight == null) {
|
|
final titlePainter = TextPainter(
|
|
text: TextSpan(text: currentTrack.name, style: titleStyle),
|
|
maxLines: 2,
|
|
textDirection: TextDirection.ltr,
|
|
)..layout(maxWidth: expandedTitleWidth);
|
|
_cachedExpandedTitleHeight = titlePainter.height;
|
|
_lastMeasuredTrackName = currentTrack.name;
|
|
}
|
|
final expandedTitleHeight = _cachedExpandedTitleHeight!;
|
|
|
|
// Calculate track info block height (title + gap + artist + gap + album)
|
|
final titleToArtistGap = 12.0;
|
|
final artistToAlbumGap = 8.0;
|
|
final artistHeight = 22.0; // Approximate height for 18px font
|
|
// Album/chapter line visibility must match render condition:
|
|
// Show when: (album exists OR audiobook) AND NOT podcast
|
|
final showAlbumLine = (currentTrack.album != null || maProvider.isPlayingAudiobook) && !maProvider.isPlayingPodcast;
|
|
final albumHeight = showAlbumLine ? 20.0 : 0.0;
|
|
final trackInfoBlockHeight = expandedTitleHeight + titleToArtistGap + artistHeight +
|
|
(showAlbumLine ? artistToAlbumGap + albumHeight : 0.0);
|
|
|
|
// Controls section heights (from bottom up):
|
|
// - Volume slider: 48px
|
|
// - Gap: 40px (was 88-48=40 from volumeTop calculation)
|
|
// - Controls row: ~70px (centered at expandedControlsTop)
|
|
// - Gap: 64px (from expandedControlsTop = expandedProgressTop + 64)
|
|
// - Progress bar + times: ~70px
|
|
// Total from bottom edge: ~48 + 40 + 70 + 64 = 222, plus safe area padding
|
|
// Position progress bar so controls section is anchored at bottom
|
|
// PERF Phase 1: Use already-batched bottomPadding instead of separate MediaQuery lookup
|
|
final bottomSafeArea = bottomPadding;
|
|
final controlsSectionHeight = 222.0; // Total height of progress + controls + volume
|
|
final playButtonHalfHeight = 36.0; // Half of the 72px play button container
|
|
final expandedProgressTop = screenSize.height - bottomSafeArea - controlsSectionHeight - 24 - playButtonHalfHeight;
|
|
|
|
// Calculate available space between art bottom and progress bar
|
|
final artBottom = expandedArtTop + expandedArtSize;
|
|
final availableSpace = expandedProgressTop - artBottom;
|
|
|
|
// Center the track info block in available space
|
|
final trackInfoTopMargin = (availableSpace - trackInfoBlockHeight) / 2;
|
|
final expandedTitleTop = artBottom + trackInfoTopMargin;
|
|
final titleTop = _lerpDouble(collapsedTitleTop, expandedTitleTop, t);
|
|
|
|
// Artist positioned dynamically based on actual title height
|
|
final collapsedArtistTop = MiniPlayerLayout.secondaryTop;
|
|
final expandedArtistTop = expandedTitleTop + expandedTitleHeight + titleToArtistGap;
|
|
final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t);
|
|
|
|
// Player name - animated to move with other text elements
|
|
// Starts at tertiary position, animates toward artist position as it fades out
|
|
final collapsedPlayerNameTop = MiniPlayerLayout.tertiaryTop;
|
|
final expandedPlayerNameTop = expandedArtistTop; // Animates toward artist final position
|
|
final playerNameTop = _lerpDouble(collapsedPlayerNameTop, expandedPlayerNameTop, t);
|
|
|
|
// Album - subtle, below artist
|
|
final expandedAlbumTop = expandedArtistTop + artistHeight + artistToAlbumGap;
|
|
|
|
// Controls - main row with comfortable touch targets
|
|
final collapsedControlsRight = 8.0;
|
|
final collapsedControlsTop = (_collapsedHeight - 34) / 2 - 6;
|
|
final expandedControlsTop = expandedProgressTop + 64;
|
|
final controlsTop = _lerpDouble(collapsedControlsTop, expandedControlsTop, t);
|
|
|
|
// Button sizes - larger in expanded for better touch targets
|
|
final skipButtonSize = _lerpDouble(28, 36, t);
|
|
final playButtonSize = _lerpDouble(34, 44, t);
|
|
final playButtonContainerSize = _lerpDouble(34, 72, t);
|
|
|
|
final expandedElementsOpacity = Curves.easeIn.transform((t - 0.5).clamp(0, 0.5) * 2);
|
|
|
|
// Volume - anchored near bottom with breathing room
|
|
final volumeTop = expandedControlsTop + 88;
|
|
|
|
// Utility buttons - below volume control
|
|
final utilityButtonsTop = volumeTop + 56;
|
|
|
|
// Check if we have multiple players for swipe gesture
|
|
final availablePlayers = _getAvailablePlayersSorted(maProvider);
|
|
final hasMultiplePlayers = availablePlayers.length > 1;
|
|
final selectedPlayerId = selectedPlayer.playerId;
|
|
|
|
// Calculate slide offset for mini player content (only when collapsed)
|
|
// Smoothly fade out horizontal slide offset (for device switching) as animation progresses
|
|
final miniPlayerSlideOffset = _slideOffset * collapsedWidth * offsetFade;
|
|
|
|
return Positioned(
|
|
left: horizontalMargin,
|
|
right: horizontalMargin,
|
|
bottom: bottomOffset,
|
|
child: GestureDetector(
|
|
// Use translucent to allow child widgets (like buttons) to receive taps
|
|
behavior: HitTestBehavior.translucent,
|
|
// Handle tap: when device list is visible, dismiss it; when collapsed, expand
|
|
onTap: isExpanded ? null : (widget.isDeviceRevealVisible ? GlobalPlayerOverlay.dismissPlayerReveal : expand),
|
|
onVerticalDragStart: (details) {
|
|
// Track start position for bottom dead zone detection
|
|
_verticalDragStartY = details.globalPosition.dy;
|
|
// Ignore while queue item is being dragged
|
|
if (_isQueueDragging) return;
|
|
// 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) {
|
|
// Ignore while queue item is being dragged
|
|
if (_isQueueDragging) return;
|
|
|
|
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) {
|
|
// Ignore while queue item is being dragged
|
|
if (_isQueueDragging) return;
|
|
|
|
// Check if swipe started in bottom dead zone (Android app switcher gesture area)
|
|
final screenHeight = MediaQuery.of(context).size.height;
|
|
final startedInBottomDeadZone = _verticalDragStartY != null &&
|
|
_verticalDragStartY! > screenHeight - _bottomDeadZone;
|
|
_verticalDragStartY = null;
|
|
|
|
// Expanded mode: swipe UP to open queue (but not from bottom dead zone)
|
|
if (isExpanded && !isQueuePanelOpen && !startedInBottomDeadZone && details.primaryVelocity != null) {
|
|
if (details.primaryVelocity! < -300) {
|
|
_toggleQueuePanel();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Finish gesture-driven expansion
|
|
_handleVerticalDragEnd(details);
|
|
},
|
|
onVerticalDragCancel: () {
|
|
// Reset state if gesture is cancelled (e.g., system takes over)
|
|
_isVerticalDragging = false;
|
|
_verticalDragStartY = null;
|
|
},
|
|
onHorizontalDragStart: (details) {
|
|
_horizontalDragStartX = details.globalPosition.dx;
|
|
// Queue panel swipe is handled by QueuePanel's Listener (bypasses gesture arena)
|
|
// Start volume drag if device reveal is visible
|
|
if (widget.isDeviceRevealVisible && !isExpanded) {
|
|
// For consecutive swipes, use local volume (API may not have updated player state yet)
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
final timeSinceLastDrag = now - _lastVolumeDragEndTime;
|
|
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
|
|
|
|
// Use local volume if we have an override AND within window
|
|
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
|
|
|
|
final startVolume = useLocalVolume
|
|
? _dragVolumeLevel // Continue from where last swipe ended
|
|
: (selectedPlayer.volumeLevel ?? 0).toDouble() / 100.0; // Fresh from player
|
|
|
|
setState(() {
|
|
_isDraggingVolume = true;
|
|
_dragVolumeLevel = startVolume;
|
|
});
|
|
_lastVolumeDragPosition = details.globalPosition;
|
|
HapticFeedback.lightImpact();
|
|
}
|
|
},
|
|
onHorizontalDragUpdate: (details) {
|
|
// Queue panel swipe is handled by QueuePanel's Listener (bypasses gesture arena)
|
|
// Volume swipe when device reveal is visible
|
|
if (widget.isDeviceRevealVisible && _isDraggingVolume && !isExpanded) {
|
|
// Check for stillness to trigger precision mode (only if enabled in settings)
|
|
final currentPosition = details.globalPosition;
|
|
if (_volumePrecisionModeEnabled && _lastVolumeDragPosition != null) {
|
|
final movement = (currentPosition - _lastVolumeDragPosition!).distance;
|
|
|
|
if (movement < _precisionStillnessThreshold) {
|
|
// Finger is still - start precision timer if not already running
|
|
if (_volumePrecisionTimer == null && !_inVolumePrecisionMode) {
|
|
_volumePrecisionTimer = Timer(
|
|
Duration(milliseconds: _precisionTriggerMs),
|
|
_enterVolumePrecisionMode,
|
|
);
|
|
}
|
|
} else {
|
|
// Finger moved - cancel timer (but don't exit precision mode if already in it)
|
|
_volumePrecisionTimer?.cancel();
|
|
_volumePrecisionTimer = null;
|
|
}
|
|
}
|
|
_lastVolumeDragPosition = currentPosition;
|
|
_lastVolumeLocalX = details.localPosition.dx;
|
|
|
|
double newVolume;
|
|
|
|
if (_inVolumePrecisionMode) {
|
|
// PRECISION MODE: Movement from entry point maps to zoomed range
|
|
// Full width of movement = precisionSensitivity (10%) change
|
|
// e.g., at 40% center: moving full width right = 50%, full width left = 30%
|
|
final offsetX = details.localPosition.dx - _volumePrecisionStartX;
|
|
final normalizedOffset = offsetX / collapsedWidth; // -1.0 to +1.0 range
|
|
final volumeChange = normalizedOffset * _precisionSensitivity;
|
|
newVolume = (_volumePrecisionZoomCenter + volumeChange).clamp(0.0, 1.0);
|
|
} else {
|
|
// NORMAL MODE: Delta-based movement (full width = 100%)
|
|
final dragDelta = details.delta.dx;
|
|
final volumeDelta = dragDelta / collapsedWidth;
|
|
newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
|
|
}
|
|
|
|
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 = _inVolumePrecisionMode ? _precisionThrottleMs : _volumeThrottleMs;
|
|
if (now - _lastVolumeUpdateTime >= throttleMs) {
|
|
_lastVolumeUpdateTime = now;
|
|
maProvider.setVolume(selectedPlayer.playerId, (newVolume * 100).round());
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Only handle player swipe in collapsed mode with multiple players
|
|
if (isExpanded || !hasMultiplePlayers) return;
|
|
|
|
// Ignore drags that started in the edge dead zone
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
final startedInDeadZone = _horizontalDragStartX != null &&
|
|
(_horizontalDragStartX! > screenWidth - _edgeDeadZone ||
|
|
_horizontalDragStartX! < _edgeDeadZone);
|
|
if (startedInDeadZone) return;
|
|
|
|
_handleHorizontalDragUpdate(details, maProvider, collapsedWidth);
|
|
},
|
|
onHorizontalDragEnd: (details) {
|
|
// Queue panel swipe is handled by QueuePanel's Listener (bypasses gesture arena)
|
|
// End volume drag
|
|
if (_isDraggingVolume) {
|
|
// Send final volume on release
|
|
maProvider.setVolume(selectedPlayer.playerId, (_dragVolumeLevel * 100).round());
|
|
_lastVolumeDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
|
|
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
|
|
_exitVolumePrecisionMode();
|
|
_lastVolumeDragPosition = null;
|
|
setState(() {
|
|
_isDraggingVolume = false;
|
|
});
|
|
HapticFeedback.lightImpact();
|
|
_horizontalDragStartX = null;
|
|
return;
|
|
}
|
|
|
|
// Ignore swipes that started near the edges (Android back gesture zone)
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
final startedInDeadZone = _horizontalDragStartX != null &&
|
|
(_horizontalDragStartX! > screenWidth - _edgeDeadZone ||
|
|
_horizontalDragStartX! < _edgeDeadZone);
|
|
_horizontalDragStartX = null;
|
|
|
|
if (startedInDeadZone) return;
|
|
|
|
if (!isExpanded && hasMultiplePlayers) {
|
|
// Collapsed mode: use finger-following handler
|
|
_handleHorizontalDragEnd(details, maProvider);
|
|
}
|
|
},
|
|
onHorizontalDragCancel: () {
|
|
// Reset state if gesture is cancelled (e.g., system takes over)
|
|
_horizontalDragStartX = null;
|
|
// Queue panel swipe is handled by QueuePanel's Listener
|
|
if (_isDraggingVolume) {
|
|
_exitVolumePrecisionMode();
|
|
_lastVolumeDragPosition = null;
|
|
setState(() => _isDraggingVolume = false);
|
|
}
|
|
if (_isDragging) {
|
|
_isDragging = false;
|
|
_slideOffset = 0.0;
|
|
setState(() {
|
|
_peekPlayer = null;
|
|
_peekImageUrl = null;
|
|
_peekTrack = null;
|
|
});
|
|
}
|
|
},
|
|
child: Container(
|
|
// PERF Phase 4: Only show shadow when meaningfully visible (t < 0.5)
|
|
// This avoids BoxDecoration allocation for majority of animation frames
|
|
// Shadow fades out quickly during first half of expansion
|
|
decoration: t < 0.5 ? BoxDecoration(
|
|
borderRadius: BorderRadius.circular(borderRadius),
|
|
boxShadow: const [_miniPlayerShadow],
|
|
) : null,
|
|
// Use foregroundDecoration for border so it renders ON TOP of content
|
|
// This prevents the album art from clipping the yellow synced border
|
|
foregroundDecoration: maProvider.isPlayerManuallySynced(selectedPlayer.playerId) && t < 0.5
|
|
? BoxDecoration(
|
|
borderRadius: BorderRadius.circular(borderRadius),
|
|
border: Border.all(color: _groupBorderColor, width: 1.5),
|
|
)
|
|
: null,
|
|
child: Material(
|
|
color: backgroundColor,
|
|
borderRadius: BorderRadius.circular(borderRadius),
|
|
elevation: 0, // PERF Phase 3: Fixed elevation, shadow handled by Container
|
|
// Use hardEdge when fully expanded (no border radius) to avoid anti-alias artifacts
|
|
// Use antiAlias when collapsed/animating to smooth rounded corners
|
|
clipBehavior: borderRadius > 0.5 ? Clip.antiAlias : Clip.hardEdge,
|
|
child: SizedBox(
|
|
width: width,
|
|
height: height,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
// Mini player progress bar background (collapsed only)
|
|
// Shows played portion with brighter color, unplayed with darker
|
|
// Starts after album art so progress is visible
|
|
// Slides with content during swipe, hidden during transition if peek available
|
|
// GPU PERF: Use conditional rendering + color alpha instead of Opacity
|
|
// to avoid double saveLayer (Opacity + ClipRRect)
|
|
if (t < 0.5 && currentTrack?.duration != null && !(_inTransition && t < 0.1 && _peekPlayer != null))
|
|
Positioned(
|
|
left: _collapsedArtSize + miniPlayerSlideOffset,
|
|
top: 0,
|
|
width: width - _collapsedArtSize,
|
|
bottom: 0,
|
|
child: ValueListenableBuilder<int>(
|
|
valueListenable: _progressNotifier,
|
|
builder: (context, elapsedSeconds, _) {
|
|
final totalSeconds = currentTrack!.duration!.inSeconds;
|
|
if (totalSeconds <= 0) return const SizedBox.shrink();
|
|
final progress = (elapsedSeconds / totalSeconds).clamp(0.0, 1.0);
|
|
// Fade out as we expand - use color alpha instead of Opacity widget
|
|
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
|
|
final progressAreaWidth = width - _collapsedArtSize;
|
|
// RepaintBoundary isolates frequent progress updates from parent animation
|
|
return RepaintBoundary(
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.only(
|
|
topRight: Radius.circular(borderRadius),
|
|
bottomRight: Radius.circular(borderRadius),
|
|
),
|
|
clipBehavior: Clip.hardEdge,
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Container(
|
|
width: progressAreaWidth * progress,
|
|
height: height,
|
|
color: collapsedBg.withOpacity(progressOpacity),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
// Peek player content (shows when dragging OR during transition)
|
|
// Show when: actively dragging (slideOffset != 0) OR in transition state
|
|
// Must have peek data to render
|
|
if (t < 0.1 && _peekPlayer != null && (_slideOffset.abs() > 0.01 || _inTransition))
|
|
_buildPeekContent(
|
|
context: context,
|
|
peekPlayer: _peekPlayer,
|
|
peekImageUrl: _peekImageUrl,
|
|
slideOffset: _slideOffset,
|
|
containerWidth: collapsedWidth,
|
|
backgroundColor: collapsedBg,
|
|
textColor: textColor,
|
|
),
|
|
|
|
// Album art - with slide animation when collapsed
|
|
// Hidden during transition to prevent flash (peek content shows instead)
|
|
// FALLBACK: Show main content if transition is active but peek content unavailable
|
|
// This prevents showing only the progress bar with no content
|
|
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
|
// PERF Phase 4: Use Transform.translate for slide offset (GPU-accelerated)
|
|
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
|
|
Positioned(
|
|
left: artLeft,
|
|
top: artTop,
|
|
child: Transform.translate(
|
|
offset: Offset(miniPlayerSlideOffset, 0),
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
// Use onTapUp instead of onTap - resolves immediately and wins
|
|
// the gesture arena against the outer vertical drag detector
|
|
onTapUp: t > 0.5 ? (_) => _showFullscreenArt(context, imageUrl) : null,
|
|
child: Container(
|
|
width: artSize,
|
|
height: artSize,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(artBorderRadius),
|
|
// PERF Phase 4: Only show shadow when near-expanded (t > 0.7)
|
|
// Avoids BoxShadow allocation during most of animation
|
|
boxShadow: t > 0.7 ? const [_artShadowExpanded] : null,
|
|
),
|
|
// Use RepaintBoundary to isolate art repaints during animation
|
|
child: RepaintBoundary(
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(artBorderRadius),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
imageUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: imageUrl,
|
|
fit: BoxFit.cover,
|
|
// Fixed cache size to avoid mid-animation cache thrashing
|
|
memCacheWidth: 512,
|
|
memCacheHeight: 512,
|
|
fadeInDuration: Duration.zero,
|
|
fadeOutDuration: Duration.zero,
|
|
placeholderFadeInDuration: Duration.zero,
|
|
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
|
|
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
|
|
)
|
|
: _buildPlaceholderArt(colorScheme, t),
|
|
// Provider icon overlay (only show when expanded enough)
|
|
if (t > 0.5 && currentTrack.providerMappings?.isNotEmpty == true)
|
|
ProviderIconOverlay(
|
|
domain: currentTrack.providerMappings!.first.providerDomain,
|
|
size: 24,
|
|
margin: 8,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Track title - with slide animation when collapsed
|
|
// When hint is visible, show lightbulb icon + "Pull to select players"
|
|
// When device reveal is visible, show player name instead of track name
|
|
// Hidden during transition to prevent flash
|
|
// FALLBACK: Show if transition active but no peek content available
|
|
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
|
// Hint text - vertically centered in mini player
|
|
// PERF Phase 4: Use Transform.translate for slide offset (GPU-accelerated)
|
|
if (widget.isHintVisible && t < 0.5 && !(_inTransition && t < 0.1 && _peekPlayer != null))
|
|
Positioned(
|
|
left: titleLeft,
|
|
// Center vertically: (64 - ~20) / 2 = 22
|
|
top: (MiniPlayerLayout.height - 20) / 2,
|
|
child: Transform.translate(
|
|
offset: Offset(miniPlayerSlideOffset, 0),
|
|
child: SizedBox(
|
|
width: titleWidth,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.lightbulb_outline,
|
|
size: 16,
|
|
color: textColor,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
S.of(context)!.pullToSelectPlayers,
|
|
style: TextStyle(
|
|
color: textColor,
|
|
fontSize: titleFontSize,
|
|
fontWeight: MiniPlayerLayout.primaryFontWeight,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Track title - with slide animation when collapsed
|
|
// Hidden when hint is visible
|
|
// Uses Align.lerp for smooth left-to-center transition (textAlign can't be animated)
|
|
// PERF Phase 4: Use Transform.translate for slide offset (GPU-accelerated)
|
|
if (!(widget.isHintVisible && t < 0.5) && !(_inTransition && t < 0.1 && _peekPlayer != null))
|
|
Positioned(
|
|
left: titleLeft,
|
|
top: titleTop,
|
|
child: Transform.translate(
|
|
offset: Offset(miniPlayerSlideOffset, 0),
|
|
child: SizedBox(
|
|
width: titleWidth,
|
|
child: Align(
|
|
// PERF Phase 5: Use pre-computed alignment
|
|
alignment: textAlignment,
|
|
child: Text(
|
|
currentTrack.name,
|
|
style: TextStyle(
|
|
color: textColor,
|
|
fontSize: titleFontSize,
|
|
// PERF Phase 5: Use pre-computed font weight
|
|
fontWeight: titleFontWeight,
|
|
// Lerp letter spacing smoothly (0 to -0.5)
|
|
letterSpacing: _lerpDouble(0, -0.5, t),
|
|
// Lerp line height smoothly (1.0 default to 1.2)
|
|
height: _lerpDouble(1.0, 1.2, t),
|
|
),
|
|
textAlign: TextAlign.left, // Keep static, Align handles centering
|
|
// Use 1 line when collapsed to prevent overlap with artist line,
|
|
// expand to 2 lines during animation for long titles
|
|
maxLines: t > 0.3 ? 2 : 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Artist/Author name - with slide animation when collapsed
|
|
// When hint is visible, show blank line
|
|
// When device reveal is visible, show "Now Playing" hint
|
|
// For audiobooks: show author from audiobook context
|
|
// Hidden during transition to prevent flash
|
|
// FALLBACK: Show if transition active but no peek content available
|
|
// Uses Align.lerp for smooth left-to-center transition
|
|
// PERF Phase 4: Use Transform.translate for slide offset (GPU-accelerated)
|
|
if (!(_inTransition && t < 0.1 && _peekPlayer != null) && !(widget.isHintVisible && t < 0.5))
|
|
Positioned(
|
|
left: titleLeft,
|
|
top: artistTop,
|
|
child: Transform.translate(
|
|
offset: Offset(miniPlayerSlideOffset, 0),
|
|
child: SizedBox(
|
|
width: titleWidth,
|
|
child: Align(
|
|
// PERF Phase 5: Use pre-computed alignment
|
|
alignment: textAlignment,
|
|
child: GestureDetector(
|
|
onTap: t > 0.8 ? () => _navigateToArtist(maProvider, currentTrack) : null,
|
|
child: Text(
|
|
// Always show artist/author/podcast name (was showing "Now Playing" when device reveal visible)
|
|
maProvider.isPlayingAudiobook
|
|
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
|
|
: maProvider.isPlayingPodcast
|
|
? (maProvider.currentPodcastName ?? S.of(context)!.podcasts)
|
|
: currentTrack.artistsString,
|
|
style: TextStyle(
|
|
// PERF Phase 4: Use Color.lerp between pre-computed colors
|
|
color: Color.lerp(textColor60, textColor70, t),
|
|
fontSize: artistFontSize,
|
|
),
|
|
textAlign: TextAlign.left, // Keep static, Align handles centering
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Player name - third line, fades out during first half of expansion
|
|
// Uses staggered opacity: fully visible at t=0, fully faded at t=0.4
|
|
// Position animates smoothly throughout
|
|
// PERF Phase 4: Use Transform.translate for slide offset (GPU-accelerated)
|
|
if (t < 0.5 && !(_inTransition && t < 0.1 && _peekPlayer != null) && !(widget.isHintVisible && t < 0.5))
|
|
Positioned(
|
|
left: titleLeft,
|
|
top: playerNameTop,
|
|
child: Transform.translate(
|
|
offset: Offset(miniPlayerSlideOffset, 0),
|
|
child: SizedBox(
|
|
width: titleWidth,
|
|
child: Align(
|
|
// PERF Phase 5: Use pre-computed alignment
|
|
alignment: textAlignment,
|
|
child: Text(
|
|
selectedPlayer.name,
|
|
style: TextStyle(
|
|
// Staggered fade: 1.0 at t=0, 0.0 at t=0.4
|
|
color: textColor.withOpacity((1.0 - t / 0.4).clamp(0.0, 1.0)),
|
|
fontSize: MiniPlayerLayout.tertiaryFontSize,
|
|
),
|
|
textAlign: TextAlign.left, // Keep static, Align handles centering
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Album name OR Chapter name (expanded only)
|
|
// GPU PERF: Use color alpha instead of Opacity widget
|
|
// For audiobooks: show current chapter; for music: show album
|
|
// For podcasts: hide album line since podcast name is already shown in artist position
|
|
if (t > 0.3 && (currentTrack.album != null || maProvider.isPlayingAudiobook) && !maProvider.isPlayingPodcast)
|
|
Positioned(
|
|
left: contentPadding,
|
|
right: contentPadding,
|
|
top: _lerpDouble(artistTop + 24, expandedAlbumTop, t),
|
|
child: maProvider.isPlayingAudiobook
|
|
? _buildChapterInfo(maProvider, textColor, t)
|
|
: GestureDetector(
|
|
onTap: t > 0.8 ? () => _navigateToAlbum(currentTrack) : null,
|
|
child: Text(
|
|
currentTrack.album!.name,
|
|
style: TextStyle(
|
|
color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w300,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
|
|
// Audio source/format info (expanded only, when Sendspin active)
|
|
// Shows "Playing from Sendspin" and audio format
|
|
if (t > 0.5 && maProvider.isSendspinConnected)
|
|
Positioned(
|
|
left: contentPadding,
|
|
right: contentPadding,
|
|
top: expandedAlbumTop + (maProvider.isPlayingAudiobook ? 24 : (currentTrack.album != null ? 32 : 0)),
|
|
child: FadeTransition(
|
|
// PERF Phase 5: Use cached animation instead of creating new Tween every frame
|
|
opacity: _fadeIn50to100,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.graphic_eq_rounded,
|
|
size: 14,
|
|
color: primaryColor70, // PERF: Use cached color
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
maProvider.currentAudioFormat ?? S.of(context)!.pcmAudio,
|
|
style: TextStyle(
|
|
color: primaryColor70, // PERF: Use cached color
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
letterSpacing: 0.3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Progress bar (expanded only)
|
|
// GPU PERF: Use FadeTransition instead of Opacity
|
|
if (t > 0.5 && currentTrack.duration != null)
|
|
Positioned(
|
|
left: contentPadding,
|
|
right: contentPadding,
|
|
top: expandedProgressTop,
|
|
child: FadeTransition(
|
|
// PERF Phase 5: Use cached animation instead of creating new Tween every frame
|
|
opacity: _fadeIn50to100,
|
|
child: ValueListenableBuilder<int>(
|
|
valueListenable: _progressNotifier,
|
|
builder: (context, elapsedTime, child) {
|
|
return ValueListenableBuilder<double?>(
|
|
valueListenable: _seekPositionNotifier,
|
|
builder: (context, seekPosition, child) {
|
|
final currentProgress = seekPosition ?? elapsedTime.toDouble();
|
|
return Column(
|
|
children: [
|
|
SizedBox(
|
|
height: 48, // Increase touch target height
|
|
child: _buildProgressSlider(
|
|
context,
|
|
currentProgress,
|
|
currentTrack.duration!.inSeconds.toDouble(),
|
|
primaryColor,
|
|
primaryColor20,
|
|
maProvider,
|
|
selectedPlayer.playerId,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
formatDurationSeconds(currentProgress.toInt()),
|
|
style: TextStyle(
|
|
color: textColor50, // PERF: Use cached color
|
|
fontSize: 13, // Increased from 11 to 13
|
|
fontWeight: FontWeight.w500,
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
Text(
|
|
formatDurationSeconds(currentTrack.duration!.inSeconds),
|
|
style: TextStyle(
|
|
color: textColor50, // PERF: Use cached color
|
|
fontSize: 13, // Increased from 11 to 13
|
|
fontWeight: FontWeight.w500,
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
|
|
// Playback controls - with slide animation when collapsed
|
|
// Uses curved interpolation to smoothly transition from right-aligned to centered
|
|
// Use skip 30s controls for audiobooks and podcasts
|
|
Positioned(
|
|
// Full width positioning, alignment handled by child Align widget
|
|
left: 0,
|
|
right: 0,
|
|
top: controlsTop,
|
|
child: (maProvider.isPlayingAudiobook || maProvider.isPlayingPodcast)
|
|
// When device reveal is visible (player list shown), use compact controls
|
|
// for audiobooks/podcasts: Play, Forward 30, Power
|
|
? widget.isDeviceRevealVisible && t < 0.5
|
|
? Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
// Play/Pause - compact like PlayerCard
|
|
Transform.translate(
|
|
offset: const Offset(3, 0),
|
|
child: SizedBox(
|
|
width: 44,
|
|
height: 44,
|
|
child: IconButton(
|
|
icon: Icon(
|
|
selectedPlayer.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
|
color: textColor,
|
|
size: 28,
|
|
),
|
|
onPressed: () => maProvider.playPauseSelectedPlayer(),
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
),
|
|
),
|
|
// Forward 30 seconds
|
|
Transform.translate(
|
|
offset: const Offset(6, 0),
|
|
child: IconButton(
|
|
icon: Icon(
|
|
Icons.forward_30_rounded,
|
|
color: textColor,
|
|
size: 28,
|
|
),
|
|
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 30),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
),
|
|
),
|
|
// Power button
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.power_settings_new_rounded,
|
|
color: selectedPlayer.powered ? textColor : textColor50,
|
|
size: 20,
|
|
),
|
|
onPressed: () => maProvider.togglePower(selectedPlayer.playerId),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
)
|
|
: _buildAudiobookControls(
|
|
maProvider: maProvider,
|
|
selectedPlayer: selectedPlayer,
|
|
textColor: textColor,
|
|
primaryColor: primaryColor,
|
|
backgroundColor: backgroundColor,
|
|
skipButtonSize: skipButtonSize,
|
|
playButtonSize: playButtonSize,
|
|
playButtonContainerSize: playButtonContainerSize,
|
|
t: t,
|
|
expandedElementsOpacity: expandedElementsOpacity,
|
|
)
|
|
// When device reveal is visible (player list shown), use compact controls
|
|
// like PlayerCard: Play, Next, Power - matching other players in the list
|
|
: widget.isDeviceRevealVisible && t < 0.5
|
|
? Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
// Play/Pause - compact like PlayerCard
|
|
// Touch target increased to 44dp for accessibility
|
|
Transform.translate(
|
|
offset: const Offset(3, 0),
|
|
child: SizedBox(
|
|
width: 44,
|
|
height: 44,
|
|
child: IconButton(
|
|
icon: Icon(
|
|
selectedPlayer.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
|
color: textColor,
|
|
size: 28,
|
|
),
|
|
onPressed: () => maProvider.playPauseSelectedPlayer(),
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
),
|
|
),
|
|
// Skip next - nudged right like PlayerCard
|
|
Transform.translate(
|
|
offset: const Offset(6, 0),
|
|
child: IconButton(
|
|
icon: Icon(
|
|
Icons.skip_next_rounded,
|
|
color: textColor,
|
|
size: 28,
|
|
),
|
|
onPressed: () => maProvider.nextTrackSelectedPlayer(),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
),
|
|
),
|
|
// Power button - smallest, like PlayerCard
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.power_settings_new_rounded,
|
|
color: selectedPlayer.powered ? textColor : textColor50,
|
|
size: 20,
|
|
),
|
|
onPressed: () => maProvider.togglePower(selectedPlayer.playerId),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
)
|
|
// Wrap in Align for smooth transition from right to center
|
|
: Align(
|
|
// Smooth alignment: lerp from right (1.0) to center (0.0)
|
|
alignment: Alignment.lerp(
|
|
Alignment(1.0 - (collapsedControlsRight / (width / 2)), 0), // Right with margin
|
|
Alignment.center,
|
|
t,
|
|
)!,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Shuffle - animate size from 0 to prevent jerk when appearing
|
|
// Always render but with animated width to smoothly grow into place
|
|
SizedBox(
|
|
width: _lerpDouble(0, 44, t), // Animate width from 0 to 44
|
|
height: 44,
|
|
child: t > 0.3 ? Opacity(
|
|
opacity: expandedElementsOpacity,
|
|
child: _buildSecondaryButton(
|
|
icon: Icons.shuffle_rounded,
|
|
color: _queue?.shuffle == true ? primaryColor : textColor50,
|
|
onPressed: _isLoadingQueue ? null : _toggleShuffle,
|
|
),
|
|
) : null,
|
|
),
|
|
SizedBox(width: _lerpDouble(0, 20, t)),
|
|
|
|
// Previous
|
|
_buildControlButton(
|
|
icon: Icons.skip_previous_rounded,
|
|
color: textColor,
|
|
size: skipButtonSize,
|
|
onPressed: () => maProvider.previousTrackSelectedPlayer(),
|
|
useAnimation: t > 0.5,
|
|
),
|
|
SizedBox(width: _lerpDouble(0, 20, t)),
|
|
|
|
// Play/Pause
|
|
_buildPlayButton(
|
|
isPlaying: selectedPlayer.isPlaying,
|
|
textColor: textColor,
|
|
primaryColor: primaryColor,
|
|
backgroundColor: backgroundColor,
|
|
size: playButtonSize,
|
|
containerSize: playButtonContainerSize,
|
|
progress: t,
|
|
onPressed: () => maProvider.playPauseSelectedPlayer(),
|
|
onLongPress: () => maProvider.stopPlayer(selectedPlayer.playerId),
|
|
),
|
|
SizedBox(width: _lerpDouble(0, 20, t)),
|
|
|
|
// Next
|
|
_buildControlButton(
|
|
icon: Icons.skip_next_rounded,
|
|
color: textColor,
|
|
size: skipButtonSize,
|
|
onPressed: () => maProvider.nextTrackSelectedPlayer(),
|
|
useAnimation: t > 0.5,
|
|
),
|
|
|
|
// Repeat - animate size from 0 to prevent jerk when appearing
|
|
SizedBox(width: _lerpDouble(0, 20, t)),
|
|
SizedBox(
|
|
width: _lerpDouble(0, 44, t), // Animate width from 0 to 44
|
|
height: 44,
|
|
child: t > 0.3 ? Opacity(
|
|
opacity: expandedElementsOpacity,
|
|
child: _buildSecondaryButton(
|
|
icon: _queue?.repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
|
color: _queue?.repeatMode != null && _queue!.repeatMode != 'off'
|
|
? primaryColor
|
|
: textColor50,
|
|
onPressed: _isLoadingQueue ? null : _cycleRepeat,
|
|
),
|
|
) : null,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Volume control (expanded only)
|
|
// GPU PERF: Use FadeTransition instead of Opacity
|
|
if (t > 0.5)
|
|
Positioned(
|
|
left: 48,
|
|
right: 48,
|
|
top: volumeTop,
|
|
child: FadeTransition(
|
|
// PERF Phase 5: Use cached animation instead of creating new Tween every frame
|
|
opacity: _fadeIn50to100,
|
|
child: VolumeControl(compact: false, accentColor: primaryColor),
|
|
),
|
|
),
|
|
|
|
// Utility row - positioned from top like volume, rises with player expansion
|
|
// Contains favourite, lyrics, queue, sleep buttons (alphabetical order)
|
|
if (t > 0.5)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
top: utilityButtonsTop,
|
|
child: Opacity(
|
|
opacity: expandedElementsOpacity,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
// Favourite button
|
|
_buildUtilityButton(
|
|
icon: _isCurrentTrackFavorite
|
|
? Icons.favorite_rounded
|
|
: Icons.favorite_border_rounded,
|
|
label: 'Favourite',
|
|
isActive: _isCurrentTrackFavorite,
|
|
activeColor: Colors.red,
|
|
textColor: textColor,
|
|
onPressed: () => _toggleCurrentTrackFavorite(currentTrack),
|
|
),
|
|
// Lyrics button
|
|
_buildUtilityButton(
|
|
icon: Icons.lyrics_outlined,
|
|
label: 'Lyrics',
|
|
isActive: isLyricsPanelOpen,
|
|
activeColor: primaryColor,
|
|
textColor: textColor,
|
|
onPressed: _toggleLyricsPanel,
|
|
),
|
|
// Queue button
|
|
_buildUtilityButton(
|
|
icon: Icons.queue_music_rounded,
|
|
label: 'Queue',
|
|
isActive: isQueuePanelOpen,
|
|
activeColor: primaryColor,
|
|
textColor: textColor,
|
|
onPressed: _toggleQueuePanel,
|
|
),
|
|
// Sleep timer button
|
|
_buildUtilityButton(
|
|
icon: maProvider.sleepTimerActive ? Icons.timer : Icons.timer_outlined,
|
|
label: 'Sleep',
|
|
isActive: maProvider.sleepTimerActive || isSleepTimerPanelOpen,
|
|
activeColor: primaryColor,
|
|
textColor: textColor,
|
|
onPressed: _toggleSleepTimerPanel,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Sleep timer panel - slides up from bottom (same mechanics as queue)
|
|
if (t > 0.5)
|
|
Positioned.fill(
|
|
child: AnimatedBuilder(
|
|
animation: _sleepTimerPanelController,
|
|
builder: (context, child) {
|
|
final progress = _sleepTimerPanelController.value;
|
|
// Use state variable for IgnorePointer (immediate response)
|
|
// Use animation value for Offstage (visual during animation)
|
|
return IgnorePointer(
|
|
ignoring: !_sleepTimerPanelVisible,
|
|
child: Offstage(
|
|
offstage: progress < 0.01 && !_sleepTimerPanelVisible,
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
child: GestureDetector(
|
|
onTap: _closeSleepTimerPanel, // Tap outside to close
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: GestureDetector(
|
|
onTap: () {}, // Prevent tap from closing when tapping panel
|
|
onVerticalDragEnd: (details) {
|
|
// Swipe down to close (same logic as queue)
|
|
final velocity = details.primaryVelocity ?? 0;
|
|
if (velocity > 150) {
|
|
_closeSleepTimerPanel(velocity: velocity / 1000);
|
|
}
|
|
},
|
|
child: RepaintBoundary(
|
|
child: SlideTransition(
|
|
position: _sleepTimerSlideAnimation,
|
|
child: _buildSleepTimerPanel(maProvider, primaryColor, textColor, expandedBg, topPadding),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Lyrics panel - slides up from bottom (same mechanics as queue/sleep timer)
|
|
if (t > 0.5)
|
|
Positioned.fill(
|
|
child: AnimatedBuilder(
|
|
animation: _lyricsPanelController,
|
|
builder: (context, child) {
|
|
final progress = _lyricsPanelController.value;
|
|
return IgnorePointer(
|
|
ignoring: !_lyricsPanelVisible,
|
|
child: Offstage(
|
|
offstage: progress < 0.01 && !_lyricsPanelVisible,
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
child: GestureDetector(
|
|
onTap: _closeLyricsPanel, // Tap outside to close
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: GestureDetector(
|
|
onTap: () {}, // Prevent tap from closing when tapping panel
|
|
behavior: HitTestBehavior.opaque, // Capture all touches in panel area
|
|
// Swipe down to close from header/non-scrollable areas
|
|
// Content area swipes are handled by Listener in _buildLyricsPanel
|
|
onVerticalDragEnd: (details) {
|
|
final velocity = details.primaryVelocity ?? 0;
|
|
if (velocity > 150) {
|
|
_closeLyricsPanel(velocity: velocity / 1000);
|
|
}
|
|
},
|
|
child: RepaintBoundary(
|
|
child: SlideTransition(
|
|
position: _lyricsSlideAnimation,
|
|
child: _buildLyricsPanel(maProvider, primaryColor, textColor, expandedBg, topPadding),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Collapse button (expanded only)
|
|
// GPU PERF: Use icon color alpha instead of Opacity
|
|
if (t > 0.3)
|
|
Positioned(
|
|
top: topPadding - 4,
|
|
left: 4,
|
|
child: IconButton(
|
|
icon: Icon(
|
|
Icons.keyboard_arrow_down_rounded,
|
|
color: textColor.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
|
size: 28,
|
|
),
|
|
onPressed: collapse,
|
|
padding: const EdgeInsets.all(12),
|
|
),
|
|
),
|
|
|
|
// Player name (expanded only)
|
|
// GPU PERF: Use text color alpha instead of Opacity
|
|
if (t > 0.5)
|
|
Positioned(
|
|
top: topPadding + 6,
|
|
left: 56,
|
|
right: 56,
|
|
child: IgnorePointer(
|
|
child: Text(
|
|
selectedPlayer.name,
|
|
style: TextStyle(
|
|
color: textColor.withOpacity(0.6 * ((t - 0.5) / 0.5).clamp(0.0, 1.0)),
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w500,
|
|
letterSpacing: 0.2,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
|
|
// Queue/Chapters panel (slides in from right)
|
|
// For audiobooks: show chapters panel
|
|
// For music: show queue panel
|
|
// PERF: Own AnimatedBuilder - only rebuilds queue panel section on queue animation
|
|
// Main player doesn't rebuild when queue slides in/out
|
|
if (t > 0.5)
|
|
Positioned.fill(
|
|
child: AnimatedBuilder(
|
|
animation: _queuePanelAnimation,
|
|
builder: (context, child) {
|
|
final queueProgress = _queuePanelAnimation.value;
|
|
return Offstage(
|
|
offstage: queueProgress == 0,
|
|
child: child,
|
|
);
|
|
},
|
|
// PERF: Child is not rebuilt - only Offstage wrapper updates
|
|
child: RepaintBoundary(
|
|
child: SlideTransition(
|
|
// PERF: Use cached animation instead of Tween.animate() every frame
|
|
position: _queueSlideAnimation,
|
|
child: maProvider.isPlayingAudiobook
|
|
? ChaptersPanel(
|
|
maProvider: maProvider,
|
|
audiobook: maProvider.currentAudiobook,
|
|
textColor: textColor,
|
|
primaryColor: primaryColor,
|
|
backgroundColor: expandedBg,
|
|
topPadding: topPadding,
|
|
onClose: _toggleQueuePanel,
|
|
)
|
|
: QueuePanel(
|
|
maProvider: maProvider,
|
|
queue: _queue,
|
|
isLoading: _isLoadingQueue,
|
|
textColor: textColor,
|
|
primaryColor: primaryColor,
|
|
backgroundColor: expandedBg,
|
|
topPadding: topPadding,
|
|
onClose: _toggleQueuePanel,
|
|
onRefresh: _loadQueue,
|
|
onDraggingChanged: (isDragging) {
|
|
_isQueueDragging = isDragging;
|
|
},
|
|
onSwipeStart: () {
|
|
// Haptic feedback when swipe gesture recognized
|
|
HapticFeedback.selectionClick();
|
|
},
|
|
onSwipeUpdate: (_) {
|
|
// No finger tracking - just wait for swipe end
|
|
// This avoids jank from direct value manipulation
|
|
},
|
|
onSwipeEnd: (velocity, totalDy) {
|
|
// Decide based on velocity and total vertical displacement
|
|
final screenHeight = MediaQuery.of(context).size.height;
|
|
final swipeProgress = totalDy / screenHeight;
|
|
final shouldClose = velocity > 150 || swipeProgress > 0.15;
|
|
|
|
if (shouldClose) {
|
|
_closeQueuePanelWithSpring();
|
|
}
|
|
// If not closing, panel stays open (no snap-back needed since we didn't move it)
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Volume swipe overlay (covers entire mini player when dragging volume)
|
|
// Only visible when device reveal is open and user is dragging
|
|
// Positioned last in Stack so it renders ON TOP of all content
|
|
// Uses AnimatedOpacity for smooth fade in/out transition
|
|
if (t < 0.5)
|
|
Positioned.fill(
|
|
child: IgnorePointer(
|
|
ignoring: !_isDraggingVolume,
|
|
child: AnimatedOpacity(
|
|
opacity: _isDraggingVolume ? 1.0 : 0.0,
|
|
duration: const Duration(milliseconds: 120),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(borderRadius),
|
|
child: Stack(
|
|
children: [
|
|
// Unfilled (darker) background
|
|
Container(color: collapsedBgUnplayed),
|
|
// Filled (lighter) portion based on volume
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: FractionallySizedBox(
|
|
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
|
|
heightFactor: 1.0,
|
|
child: Container(color: collapsedBg),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Build the peek player content that slides in from the edge during drag
|
|
/// Uses shared MiniPlayerContent for consistency
|
|
Widget _buildPeekContent({
|
|
required BuildContext context,
|
|
required dynamic peekPlayer,
|
|
required String? peekImageUrl,
|
|
required double slideOffset,
|
|
required double containerWidth,
|
|
required Color backgroundColor,
|
|
required Color textColor,
|
|
}) {
|
|
// Calculate sliding position
|
|
double peekBaseOffset;
|
|
|
|
if (_inTransition && slideOffset.abs() < 0.01) {
|
|
peekBaseOffset = 0.0;
|
|
} else {
|
|
final isFromRight = slideOffset < 0;
|
|
final peekProgress = slideOffset.abs();
|
|
peekBaseOffset = isFromRight
|
|
? containerWidth * (1 - peekProgress)
|
|
: -containerWidth * (1 - peekProgress);
|
|
}
|
|
|
|
// Check if peek player has a track
|
|
final hasTrack = _peekTrack != null && peekImageUrl != null;
|
|
final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? S.of(context)!.unknown);
|
|
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : S.of(context)!.swipeToSwitchDevice;
|
|
// Show player name as third line only when playing
|
|
final peekPlayerName = hasTrack ? (peekPlayer?.name ?? '') : null;
|
|
|
|
return Transform.translate(
|
|
offset: Offset(peekBaseOffset, 0),
|
|
child: MiniPlayerContent(
|
|
primaryText: peekTrackName,
|
|
secondaryText: peekArtistName,
|
|
tertiaryText: peekPlayerName,
|
|
imageUrl: hasTrack ? peekImageUrl : null,
|
|
playerName: peekPlayer?.name ?? '',
|
|
backgroundColor: backgroundColor,
|
|
textColor: textColor,
|
|
width: containerWidth,
|
|
slideOffset: 0, // Transform handles positioning
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
/// Build peek player progress bar that slides in during swipe
|
|
Widget _buildPeekProgressBar({
|
|
required double slideOffset,
|
|
required bool inTransition,
|
|
required double collapsedWidth,
|
|
required double progressAreaWidth,
|
|
required double height,
|
|
required double borderRadius,
|
|
required Color color,
|
|
}) {
|
|
// Calculate peek progress bar position (mirrors _buildPeekContent positioning)
|
|
double peekProgressLeft;
|
|
if (inTransition && slideOffset.abs() < 0.01) {
|
|
peekProgressLeft = _collapsedArtSize;
|
|
} else {
|
|
final isFromRight = slideOffset < 0;
|
|
final peekProgress = slideOffset.abs();
|
|
peekProgressLeft = _collapsedArtSize + (isFromRight
|
|
? collapsedWidth * (1 - peekProgress)
|
|
: -collapsedWidth * (1 - peekProgress));
|
|
}
|
|
|
|
// Get peek player's elapsed time - safely handle null/invalid values
|
|
final peekElapsed = _peekPlayer?.currentElapsed ?? 0.0;
|
|
final peekTotal = _peekTrack?.duration?.inSeconds ?? 0;
|
|
if (peekTotal <= 0) return const SizedBox.shrink();
|
|
final peekProgressValue = (peekElapsed / peekTotal).clamp(0.0, 1.0);
|
|
|
|
return Positioned(
|
|
left: peekProgressLeft,
|
|
top: 0,
|
|
width: progressAreaWidth,
|
|
bottom: 0,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.only(
|
|
topRight: Radius.circular(borderRadius),
|
|
bottomRight: Radius.circular(borderRadius),
|
|
),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Container(
|
|
width: progressAreaWidth * peekProgressValue,
|
|
height: height,
|
|
color: color,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) {
|
|
// Use theme-aware colors for both collapsed and expanded states
|
|
final expandedBgColor = colorScheme.surfaceContainerHighest;
|
|
final expandedIconColor = colorScheme.onSurface.withOpacity(0.24);
|
|
return Container(
|
|
color: Color.lerp(colorScheme.surfaceVariant, expandedBgColor, t),
|
|
child: Icon(
|
|
Icons.music_note_rounded,
|
|
color: Color.lerp(colorScheme.onSurfaceVariant, expandedIconColor, t),
|
|
size: _lerpDouble(24, 120, t),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Build chapter info display for audiobooks
|
|
Widget _buildChapterInfo(MusicAssistantProvider maProvider, Color textColor, double t) {
|
|
final chapter = maProvider.getCurrentChapter();
|
|
final chapterIndex = maProvider.getCurrentChapterIndex();
|
|
final totalChapters = maProvider.currentAudiobook?.chapters?.length ?? 0;
|
|
|
|
if (chapter == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final chapterText = totalChapters > 0
|
|
? 'Chapter ${chapterIndex + 1}/$totalChapters: ${chapter.title}'
|
|
: chapter.title;
|
|
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.bookmark_outline_rounded,
|
|
size: 14,
|
|
color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
chapterText,
|
|
style: TextStyle(
|
|
color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Build audiobook-specific playback controls
|
|
/// Collapsed: [-30s] [Play/Pause] [+30s]
|
|
/// Expanded: [Prev Chapter] [-30s] [-10s] [Play/Pause] [+10s] [+30s] [Next Chapter]
|
|
Widget _buildAudiobookControls({
|
|
required MusicAssistantProvider maProvider,
|
|
required dynamic selectedPlayer,
|
|
required Color textColor,
|
|
required Color primaryColor,
|
|
required Color backgroundColor,
|
|
required double skipButtonSize,
|
|
required double playButtonSize,
|
|
required double playButtonContainerSize,
|
|
required double t,
|
|
required double expandedElementsOpacity,
|
|
}) {
|
|
final hasChapters = maProvider.currentAudiobook?.chapters?.isNotEmpty ?? false;
|
|
final isExpanded = t > 0.5;
|
|
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
|
|
children: [
|
|
// Previous Chapter (expanded only, if chapters available)
|
|
if (isExpanded && expandedElementsOpacity > 0.1 && hasChapters)
|
|
_buildSecondaryButton(
|
|
icon: Icons.skip_previous_rounded,
|
|
color: textColor.withOpacity(expandedElementsOpacity),
|
|
onPressed: () => maProvider.seekToPreviousChapter(selectedPlayer.playerId),
|
|
),
|
|
if (isExpanded && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
|
|
|
|
// Rewind 30 seconds
|
|
_buildControlButton(
|
|
icon: Icons.replay_30_rounded,
|
|
color: textColor,
|
|
size: skipButtonSize,
|
|
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, -30),
|
|
useAnimation: isExpanded,
|
|
),
|
|
|
|
// Rewind 10 seconds (expanded only, same size as 30s)
|
|
if (isExpanded) ...[
|
|
SizedBox(width: _lerpDouble(0, 8, t)),
|
|
_buildControlButton(
|
|
icon: Icons.replay_10_rounded,
|
|
color: textColor,
|
|
size: skipButtonSize,
|
|
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, -10),
|
|
useAnimation: true,
|
|
),
|
|
],
|
|
SizedBox(width: _lerpDouble(0, 12, t)),
|
|
|
|
// Play/Pause
|
|
_buildPlayButton(
|
|
isPlaying: selectedPlayer.isPlaying,
|
|
textColor: textColor,
|
|
primaryColor: primaryColor,
|
|
backgroundColor: backgroundColor,
|
|
size: playButtonSize,
|
|
containerSize: playButtonContainerSize,
|
|
progress: t,
|
|
onPressed: () => maProvider.playPauseSelectedPlayer(),
|
|
onLongPress: () => maProvider.stopPlayer(selectedPlayer.playerId),
|
|
),
|
|
SizedBox(width: _lerpDouble(0, 12, t)),
|
|
|
|
// Forward 10 seconds (expanded only, same size as 30s)
|
|
if (isExpanded) ...[
|
|
_buildControlButton(
|
|
icon: Icons.forward_10_rounded,
|
|
color: textColor,
|
|
size: skipButtonSize,
|
|
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 10),
|
|
useAnimation: true,
|
|
),
|
|
SizedBox(width: _lerpDouble(0, 8, t)),
|
|
],
|
|
|
|
// Forward 30 seconds
|
|
_buildControlButton(
|
|
icon: Icons.forward_30_rounded,
|
|
color: textColor,
|
|
size: skipButtonSize,
|
|
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 30),
|
|
useAnimation: isExpanded,
|
|
),
|
|
|
|
// Next Chapter (expanded only, if chapters available)
|
|
if (isExpanded && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
|
|
if (isExpanded && expandedElementsOpacity > 0.1 && hasChapters)
|
|
_buildSecondaryButton(
|
|
icon: Icons.skip_next_rounded,
|
|
color: textColor.withOpacity(expandedElementsOpacity),
|
|
onPressed: () => maProvider.seekToNextChapter(selectedPlayer.playerId),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildControlButton({
|
|
required IconData icon,
|
|
required Color color,
|
|
required double size,
|
|
required VoidCallback onPressed,
|
|
required bool useAnimation,
|
|
}) {
|
|
if (useAnimation) {
|
|
return AnimatedIconButton(
|
|
icon: icon,
|
|
color: color,
|
|
iconSize: size,
|
|
onPressed: onPressed,
|
|
);
|
|
}
|
|
return IconButton(
|
|
icon: Icon(icon),
|
|
color: color,
|
|
iconSize: size,
|
|
onPressed: onPressed,
|
|
padding: const EdgeInsets.all(4),
|
|
constraints: const BoxConstraints(),
|
|
);
|
|
}
|
|
|
|
Widget _buildSecondaryButton({
|
|
required IconData icon,
|
|
required Color color,
|
|
VoidCallback? onPressed,
|
|
}) {
|
|
return SizedBox(
|
|
width: 44,
|
|
height: 44,
|
|
child: AnimatedIconButton(
|
|
icon: icon,
|
|
color: color,
|
|
iconSize: 22,
|
|
onPressed: onPressed,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlayButton({
|
|
required bool isPlaying,
|
|
required Color textColor,
|
|
required Color primaryColor,
|
|
required Color backgroundColor,
|
|
required double size,
|
|
required double containerSize,
|
|
required double progress,
|
|
required VoidCallback onPressed,
|
|
VoidCallback? onLongPress,
|
|
}) {
|
|
final bgColor = Color.lerp(Colors.transparent, primaryColor, progress);
|
|
final iconColor = Color.lerp(textColor, backgroundColor, progress);
|
|
|
|
return GestureDetector(
|
|
onLongPress: onLongPress,
|
|
child: Container(
|
|
width: containerSize,
|
|
height: containerSize,
|
|
decoration: BoxDecoration(
|
|
color: bgColor,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: IconButton(
|
|
icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded),
|
|
color: iconColor,
|
|
iconSize: size,
|
|
onPressed: onPressed,
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
double _lerpDouble(double a, double b, double t) {
|
|
return a + (b - a) * t;
|
|
}
|
|
|
|
Widget _buildUtilityButton({
|
|
required IconData icon,
|
|
required String label,
|
|
required bool isActive,
|
|
required Color activeColor,
|
|
required Color textColor,
|
|
required VoidCallback onPressed,
|
|
}) {
|
|
final color = isActive ? activeColor : textColor.withOpacity(0.7);
|
|
return GestureDetector(
|
|
onTap: onPressed,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, color: color, size: 24),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 12,
|
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSleepTimerPanel(MusicAssistantProvider maProvider, Color primaryColor, Color textColor, Color backgroundColor, double topPadding) {
|
|
final remaining = maProvider.sleepTimerRemaining;
|
|
final currentMinutes = maProvider.sleepTimerMinutes;
|
|
|
|
// Format remaining time
|
|
String? remainingText;
|
|
if (remaining != null && currentMinutes != -1) {
|
|
final mins = remaining.inMinutes;
|
|
final secs = remaining.inSeconds % 60;
|
|
if (mins > 0) {
|
|
remainingText = '$mins:${secs.toString().padLeft(2, '0')} remaining';
|
|
} else {
|
|
remainingText = '$secs sec remaining';
|
|
}
|
|
} else if (currentMinutes == -1) {
|
|
remainingText = 'Will stop after this track';
|
|
}
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: backgroundColor,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Header with handle and close button
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 8, 0),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: Icon(Icons.keyboard_arrow_down_rounded, color: textColor, size: 28),
|
|
onPressed: _closeSleepTimerPanel,
|
|
padding: const EdgeInsets.all(8),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Sleep Timer',
|
|
style: TextStyle(
|
|
color: textColor,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
if (remainingText != null)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: primaryColor.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Text(
|
|
remainingText,
|
|
style: TextStyle(
|
|
color: primaryColor,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Timer options as list tiles
|
|
_buildTimerOption(maProvider, 'Off', null, primaryColor, textColor),
|
|
_buildTimerOption(maProvider, '15 minutes', 15, primaryColor, textColor),
|
|
_buildTimerOption(maProvider, '30 minutes', 30, primaryColor, textColor),
|
|
_buildTimerOption(maProvider, '45 minutes', 45, primaryColor, textColor),
|
|
_buildTimerOption(maProvider, '1 hour', 60, primaryColor, textColor),
|
|
_buildTimerOption(maProvider, '2 hours', 120, primaryColor, textColor),
|
|
_buildTimerOption(maProvider, 'End of track', -1, primaryColor, textColor),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTimerOption(MusicAssistantProvider maProvider, String label, int? minutes, Color primaryColor, Color textColor) {
|
|
final currentMinutes = maProvider.sleepTimerMinutes;
|
|
final isSelected = currentMinutes == minutes;
|
|
|
|
return ListTile(
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
|
title: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: isSelected ? primaryColor : textColor,
|
|
fontSize: 16,
|
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
|
),
|
|
),
|
|
trailing: isSelected
|
|
? Icon(Icons.check_rounded, color: primaryColor, size: 24)
|
|
: null,
|
|
onTap: () {
|
|
maProvider.setSleepTimer(minutes);
|
|
_closeSleepTimerPanel();
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildLyricsPanel(MusicAssistantProvider maProvider, Color primaryColor, Color textColor, Color backgroundColor, double topPadding) {
|
|
final currentTrack = maProvider.currentTrack;
|
|
final screenHeight = MediaQuery.of(context).size.height;
|
|
|
|
return Container(
|
|
height: screenHeight * 0.7, // 70% of screen height
|
|
decoration: BoxDecoration(
|
|
color: backgroundColor,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Column(
|
|
children: [
|
|
// Header (matches queue and sleep timer style)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 8, 0),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: Icon(Icons.keyboard_arrow_down_rounded, color: textColor, size: 28),
|
|
onPressed: _closeLyricsPanel,
|
|
padding: const EdgeInsets.all(8),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Lyrics',
|
|
style: TextStyle(
|
|
color: textColor,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
const SizedBox(width: 16),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Divider(color: textColor.withOpacity(0.1), height: 1),
|
|
// Lyrics content
|
|
Expanded(
|
|
child: _isLoadingLyrics
|
|
? Center(
|
|
child: CircularProgressIndicator(
|
|
color: primaryColor,
|
|
strokeWidth: 2,
|
|
),
|
|
)
|
|
: _currentLyrics != null
|
|
? _hasSyncedLyrics
|
|
// Synced lyrics - show as list with highlighting
|
|
// Wrapped with Listener for swipe-to-close when scrolled to top
|
|
? Listener(
|
|
onPointerDown: (event) {
|
|
_lyricsSwipeStart = event.position;
|
|
_wasLyricsAtTopOnSwipeStart = _isLyricsAtTop;
|
|
},
|
|
onPointerMove: (event) {
|
|
if (_lyricsSwipeStart == null) return;
|
|
final dy = event.position.dy - _lyricsSwipeStart!.dy;
|
|
final dx = (event.position.dx - _lyricsSwipeStart!.dx).abs();
|
|
// Only trigger swipe-to-close if at top when started and swiping down
|
|
if (dy > 20 && dy > dx * 1.5 && _wasLyricsAtTopOnSwipeStart && !_isSwipingLyricsContent) {
|
|
_isSwipingLyricsContent = true;
|
|
}
|
|
},
|
|
onPointerUp: (event) {
|
|
if (_isSwipingLyricsContent && _lyricsSwipeStart != null) {
|
|
final dy = event.position.dy - _lyricsSwipeStart!.dy;
|
|
if (dy > 50) {
|
|
// Calculate velocity (simplified)
|
|
final velocity = dy / 200; // Approximate velocity
|
|
_closeLyricsPanel(velocity: velocity);
|
|
}
|
|
}
|
|
_lyricsSwipeStart = null;
|
|
_isSwipingLyricsContent = false;
|
|
},
|
|
onPointerCancel: (_) {
|
|
_lyricsSwipeStart = null;
|
|
_isSwipingLyricsContent = false;
|
|
},
|
|
child: NotificationListener<ScrollNotification>(
|
|
onNotification: (notification) {
|
|
// Detect user scrolling to pause auto-scroll
|
|
if (notification is ScrollStartNotification &&
|
|
notification.dragDetails != null) {
|
|
_userScrollingLyrics = true;
|
|
} else if (notification is ScrollEndNotification) {
|
|
// Resume auto-scroll after a delay
|
|
Future.delayed(const Duration(seconds: 3), () {
|
|
if (mounted) _userScrollingLyrics = false;
|
|
});
|
|
}
|
|
return false;
|
|
},
|
|
child: ListView.builder(
|
|
controller: _lyricsScrollController,
|
|
padding: const EdgeInsets.only(top: 16, bottom: 100, left: 24, right: 24),
|
|
itemCount: _parsedLyrics!.length,
|
|
itemBuilder: (context, index) {
|
|
final line = _parsedLyrics![index];
|
|
final isActive = index == _currentLyricIndex;
|
|
final isPast = index < _currentLyricIndex;
|
|
|
|
return GestureDetector(
|
|
onTap: () {
|
|
// Tap to seek to this line
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final player = maProvider.selectedPlayer;
|
|
if (player != null) {
|
|
maProvider.api?.seek(player.playerId, line.timestamp.inSeconds);
|
|
}
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
child: Text(
|
|
line.text,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: isActive
|
|
? primaryColor
|
|
: isPast
|
|
? textColor.withOpacity(0.4)
|
|
: textColor.withOpacity(0.7),
|
|
fontSize: isActive ? 20 : 18,
|
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
)
|
|
// Plain lyrics - show as scrollable text
|
|
// Wrapped with Listener for swipe-to-close (always at "top" for plain text)
|
|
: Listener(
|
|
onPointerDown: (event) {
|
|
_lyricsSwipeStart = event.position;
|
|
_wasLyricsAtTopOnSwipeStart = true; // Plain lyrics always count as at top
|
|
},
|
|
onPointerMove: (event) {
|
|
if (_lyricsSwipeStart == null) return;
|
|
final dy = event.position.dy - _lyricsSwipeStart!.dy;
|
|
final dx = (event.position.dx - _lyricsSwipeStart!.dx).abs();
|
|
// Only trigger swipe-to-close if swiping down
|
|
if (dy > 20 && dy > dx * 1.5 && _wasLyricsAtTopOnSwipeStart && !_isSwipingLyricsContent) {
|
|
_isSwipingLyricsContent = true;
|
|
}
|
|
},
|
|
onPointerUp: (event) {
|
|
if (_isSwipingLyricsContent && _lyricsSwipeStart != null) {
|
|
final dy = event.position.dy - _lyricsSwipeStart!.dy;
|
|
if (dy > 50) {
|
|
// Calculate velocity (simplified)
|
|
final velocity = dy / 200; // Approximate velocity
|
|
_closeLyricsPanel(velocity: velocity);
|
|
}
|
|
}
|
|
_lyricsSwipeStart = null;
|
|
_isSwipingLyricsContent = false;
|
|
},
|
|
onPointerCancel: (_) {
|
|
_lyricsSwipeStart = null;
|
|
_isSwipingLyricsContent = false;
|
|
},
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(
|
|
_currentLyrics!,
|
|
style: TextStyle(
|
|
color: textColor,
|
|
fontSize: 16,
|
|
height: 1.8,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
)
|
|
: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.lyrics_outlined,
|
|
color: textColor.withOpacity(0.3),
|
|
size: 64,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No lyrics available',
|
|
style: TextStyle(
|
|
color: textColor.withOpacity(0.5),
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Lyrics may not be available for this track',
|
|
style: TextStyle(
|
|
color: textColor.withOpacity(0.3),
|
|
fontSize: 14,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Custom progress slider that doesn't require Overlay widget
|
|
/// (Flutter 3.38's Material 3 Slider uses OverlayPortal which fails in our overlay context)
|
|
Widget _buildProgressSlider(
|
|
BuildContext context,
|
|
double currentProgress,
|
|
double maxProgress,
|
|
Color activeColor,
|
|
Color inactiveColor,
|
|
MusicAssistantProvider maProvider,
|
|
String playerId,
|
|
) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final width = constraints.maxWidth;
|
|
final progress = maxProgress > 0 ? (currentProgress / maxProgress).clamp(0.0, 1.0) : 0.0;
|
|
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onHorizontalDragStart: (details) {
|
|
final newProgress = (details.localPosition.dx / width).clamp(0.0, 1.0);
|
|
_seekPositionNotifier.value = newProgress * maxProgress;
|
|
},
|
|
onHorizontalDragUpdate: (details) {
|
|
final newProgress = (details.localPosition.dx / width).clamp(0.0, 1.0);
|
|
_seekPositionNotifier.value = newProgress * maxProgress;
|
|
},
|
|
onHorizontalDragCancel: () {
|
|
_seekPositionNotifier.value = null;
|
|
},
|
|
onHorizontalDragEnd: (details) async {
|
|
final seekPosition = _seekPositionNotifier.value;
|
|
if (seekPosition != null && !_isSeeking) {
|
|
_isSeeking = true;
|
|
try {
|
|
await maProvider.seek(playerId, seekPosition.round());
|
|
// Wait for server to process seek before allowing another
|
|
await Future.delayed(const Duration(milliseconds: 1500));
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(S.of(context)!.errorSeeking(e.toString()))),
|
|
);
|
|
}
|
|
} finally {
|
|
_isSeeking = false;
|
|
if (mounted) {
|
|
_seekPositionNotifier.value = null;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
onTapUp: (details) async {
|
|
if (_isSeeking) return; // Ignore if already seeking
|
|
|
|
// Calculate position from tap location
|
|
final newProgress = (details.localPosition.dx / width).clamp(0.0, 1.0);
|
|
final seekPosition = newProgress * maxProgress;
|
|
_seekPositionNotifier.value = seekPosition;
|
|
_isSeeking = true;
|
|
|
|
try {
|
|
await maProvider.seek(playerId, seekPosition.round());
|
|
// Wait for server to process seek before allowing another
|
|
await Future.delayed(const Duration(milliseconds: 1500));
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(S.of(context)!.errorSeeking(e.toString()))),
|
|
);
|
|
}
|
|
} finally {
|
|
_isSeeking = false;
|
|
if (mounted) {
|
|
_seekPositionNotifier.value = null;
|
|
}
|
|
}
|
|
},
|
|
child: SizedBox(
|
|
height: 48,
|
|
child: Center(
|
|
child: SizedBox(
|
|
height: 20,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
// Inactive track (full width, thinner)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
top: 8.5,
|
|
child: Container(
|
|
height: 3,
|
|
decoration: BoxDecoration(
|
|
color: inactiveColor,
|
|
borderRadius: BorderRadius.circular(1.5),
|
|
),
|
|
),
|
|
),
|
|
// Active track
|
|
Positioned(
|
|
left: 0,
|
|
top: 7,
|
|
child: Container(
|
|
width: width * progress,
|
|
height: 6,
|
|
decoration: BoxDecoration(
|
|
color: activeColor,
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
),
|
|
),
|
|
// Thumb
|
|
Positioned(
|
|
left: (width * progress) - 7,
|
|
top: 3,
|
|
child: Container(
|
|
width: 14,
|
|
height: 14,
|
|
decoration: BoxDecoration(
|
|
color: activeColor,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Represents a single line of synced lyrics with timestamp
|
|
class _LyricLine {
|
|
final Duration timestamp;
|
|
final String text;
|
|
|
|
const _LyricLine({required this.timestamp, required this.text});
|
|
}
|