This PR represents a testing and debugging cycle following v3.0.2. Initial work on sync group volume management uncovered multiple UI bugs that required fixes. The final result addresses:
1. **Fix: Group Player Volume Management** - Two distinct volume control issues for Music Assistant sync groups
2. **Feature: Hardware Volume Button Integration** - Phone volume buttons now control Music Assistant player volume from lockscreen / shade widget / app minimised
3. **UI Bug Fixes** - Five UI issues discovered during sync group testing, which had to be fixed to get the sync groups to work without crashes
4. **Fix: Cold Start Homepage Race Condition** - Intermittent empty homepage on app cold start - surfaced once sync group volume calculations were introduced.
5. **Code Quality: Cleanup and Optimization** - Removes dead code and eliminates duplicate logic - mine, not yours, I tried to not to touch your code as much as possible
All changes have been tested and verified with `dart analyze` showing no new issues.
---
**Issue:** Volume control for Music Assistant sync groups had two distinct problems depending on how the group was created:
1. **Server-side groups**: Volume slider would snap back to 0 immediately after release
2. **In-app created groups**: Adjusting the leader's volume only changed that one player, not all members proportionally
**Root Cause:** The Music Assistant API returns `volume_level: null` for certain group players (pre-configured speaker groups with `provider == 'player_group'` and some server-side sync groups). The app had no logic to handle this case, so the null volume was interpreted as 0.
**Solution:** Implemented `GroupVolumeManager` service with dual-tier volume protection:
- **Tier 1 (GroupVolumeManager):** Computes effective volume from group members (detects groups, resolves nested, averages volumes, caches pending for 30s, confirms with ±3 tolerance).
- **Tier 2 (Provider-level):** Optimistic update protection (caches for 8s/30s, prevents stale overwrites).
**Root Cause:** For in-app groups, leader has non-null volume, so `setVolume()` only affects leader, not members.
**Expected behavior:** Leader adjustment changes all members proportionally; individual adjustments affect only that member.
**Solution:** In `setVolume()`, for group leaders with non-null volume:
1. Calculate proportional: `memberNewVol = (memberCurrentVol * newVol / leaderCurrentVol).round()` (handle zero case with absolute).
2. Cache pending, update optimistically, fire parallel API calls.
**Files Changed:**
- `lib/services/group_volume_manager.dart` (NEW - 162 lines)
- `lib/providers/music_assistant_provider.dart` (added manager, helper, updated events/state/volume methods)
---
**Feature:** Phone hardware volume buttons (including lockscreen) control MA player volume, with system HUD mirroring.
**Implementation:**
- **Android Native:** `MainActivity.kt` (~120 lines) - VolumeContentObserver, MethodChannel commands, lifecycle.
- **Flutter Service:** `hardware_volume_service.dart` (~65 lines) - streams, observer methods, sync.
- **Integration:** `main.dart` (~35 lines) - adjust/set/update methods, subscription.
**Why This Matters:** Seamless remote control from lockscreen/tray/buttons.
---
During sync group testing, fixed these UI bugs:
**File:** `lib/main.dart`
**Issue:** Console warnings during init.
**Root Cause:** Init code outside `runZonedGuarded()`.
**Fix:** Move init inside `runZonedGuarded()`.
---
**Files:** `lib/screens/home_screen.dart`, `lib/widgets/global_player_overlay.dart`
**Issue:** Crash on nav interaction.
**Root Cause:** Nav in Stack without Overlay.
**Fix:** Move nav to Scaffold.bottomNavigationBar, add extendBody: true.
---
**Files:** `lib/widgets/artist_card.dart`, `lib/widgets/album_card.dart`
**Issue:** Warnings causing corruption.
**Root Cause:** Service calls in initState trigger setState during build.
**Fix:** Defer with addPostFrameCallback, add mounted guard.
---
**File:** `lib/screens/new_library_screen.dart`
**Issue:** Crash on library tabs.
**Root Cause:** context.select in sliver context.
**Fix:** Use Consumer wrappers for tabs.
---
**File:** `lib/widgets/player/queue_panel.dart`
**Issue:** Crash on long-press buttons.
**Root Cause:** Tooltips require Overlay, but in Stack.
**Fix:** Remove tooltip properties.
---
**File:** `lib/screens/settings_screen.dart`
**Issue:** Back button crashes to black screen.
**Root Cause:** Navigator.pop pops home screen.
**Fix:** Use showPlayer + setSelectedIndex(0).
---
**Issue:** Intermittent empty homepage on cold start.
**Root Cause:** isConnected true before auth complete, leading to failed fetches.
**Solution:** Delay broadcasting 'connected' state until auth if required (`music_assistant_provider.dart:1058-1101`).
---
The `GroupVolumeManager` is a stateful service that handles **server-side groups with null volume**:
1. **Detects group players:**
```dart
bool isGroupPlayer(Player player) {
// Pre-configured MA speaker groups
if (player.provider == 'player_group') return true;
// Dynamic sync groups: have multiple members but MA returns null volume
if (player.groupMembers != null &&
player.groupMembers!.length > 1 &&
player.volumeLevel == null) {
return true;
}
return false;
}
```
2. **Computes effective volume:**
- Recursively resolves nested groups (max depth 3)
- Averages volumes across all leaf members
- Skips self-references in `groupMembers`
- Returns `null` if no members have valid volumes
3. **Caches pending volumes:**
- When user sets volume: stored with timestamp
- Returns cached value if within 30s timeout
- Clears cache when member average converges (±3 tolerance)
- Falls back to computed average after timeout
4. **Integrates with provider:**
- Called from `_applyVolumeProtection()` in provider
- Receives fresh `allPlayers` list for accurate member volumes
- Injects effective volume into `_selectedPlayer` via `copyWith()`
For **in-app created groups with non-null leader volume**, proportional propagation works as follows:
1. **Detection:** `setVolume()` checks if `player.isGroupLeader && !isGroupPlayer(player)`
- Leader has `groupMembers` with 2+ entries
- Leader has its own non-null `volumeLevel`
- NOT detected as a group player (GroupVolumeManager handles those)
2. **Calculation:** For each member in `groupMembers` (excluding leader itself):
```dart
if (leaderCurrentVol > 0) {
memberNewVol = (memberVol * newVol / leaderCurrentVol).round().clamp(0, 100)
} else {
memberNewVol = newVol // Can't ratio from zero
}
```
3. **Execution:**
- Cache `_pendingVolumes[memberId]` and `_pendingVolumeTimestamps[memberId]`
- Update `_availablePlayers[idx]` optimistically
- Fire `_api?.setVolume(memberId, memberNewVol)` without awaiting
- Continue to send leader's own `volume_set` command
4. **Result:** All member volumes scale proportionally with the leader, preserving relative balance.
```
WebSocket connects → MAConnectionState.connected
↓
Auth required?
↙ ↘
YES NO
↓ ↓
Don't notify Set state + notify
Try auth ↓
↓ Initialize
Success?
↙ ↘
YES NO
↓ ↓
auth error state
event + notify
↓
Set state
+ notify
↓
Initialize
```
**Key:** Listeners only see `isConnected = true` after auth completes (or immediately if no auth required).
android/.../MainActivity.kt | 120 + (NEW - ContentObserver)
lib/main.dart | 207 ±
lib/providers/music_assistant_provider.dart | 165 ±
lib/screens/home_screen.dart | 172 ± (BottomNav moved here)
lib/screens/new_library_screen.dart | 70 ± (Consumer wrappers)
lib/screens/settings_screen.dart | 9 ± (back button fix)
lib/services/group_volume_manager.dart | 162 + (NEW)
lib/services/hardware_volume_service.dart | 65 + (NEW)
lib/widgets/album_card.dart | 23 ± (postFrameCallback)
lib/widgets/artist_card.dart | 23 ± (postFrameCallback)
lib/widgets/global_player_overlay.dart | 171 - (BottomNav moved out)
lib/widgets/player/queue_panel.dart | 2 - (tooltip removed)
**Total:** ~650 added, ~300 removed/refactored.
1118 lines
39 KiB
Dart
1118 lines
39 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../../providers/music_assistant_provider.dart';
|
|
import '../../models/player.dart';
|
|
import '../../theme/design_tokens.dart';
|
|
import '../../utils/duration_formatter.dart';
|
|
import '../common/empty_state.dart';
|
|
import '../global_player_overlay.dart';
|
|
|
|
/// Panel that displays the current playback queue with drag-to-reorder
|
|
/// and swipe-left-to-delete functionality.
|
|
///
|
|
/// Uses custom AnimatedList implementation instead of great_list_view
|
|
/// to avoid grey screen bugs (ReorderableListView) and drag duplication bugs (great_list_view).
|
|
class QueuePanel extends StatefulWidget {
|
|
final MusicAssistantProvider maProvider;
|
|
final PlayerQueue? queue;
|
|
final bool isLoading;
|
|
final Color textColor;
|
|
final Color primaryColor;
|
|
final Color backgroundColor;
|
|
final double topPadding;
|
|
final VoidCallback onClose;
|
|
final VoidCallback onRefresh;
|
|
final ValueChanged<bool>? onDraggingChanged;
|
|
final VoidCallback? onSwipeStart;
|
|
final ValueChanged<double>? onSwipeUpdate; // dy delta from start (vertical swipe)
|
|
final void Function(double velocity, double totalDy)? onSwipeEnd; // velocity and total displacement (vertical)
|
|
|
|
const QueuePanel({
|
|
super.key,
|
|
required this.maProvider,
|
|
required this.queue,
|
|
required this.isLoading,
|
|
required this.textColor,
|
|
required this.primaryColor,
|
|
required this.backgroundColor,
|
|
required this.topPadding,
|
|
required this.onClose,
|
|
required this.onRefresh,
|
|
this.onDraggingChanged,
|
|
this.onSwipeStart,
|
|
this.onSwipeUpdate,
|
|
this.onSwipeEnd,
|
|
});
|
|
|
|
@override
|
|
State<QueuePanel> createState() => _QueuePanelState();
|
|
}
|
|
|
|
class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateMixin {
|
|
List<QueueItem> _items = [];
|
|
final GlobalKey _stackKey = GlobalKey();
|
|
|
|
// Scroll controller to track list position (for swipe-to-close only at top)
|
|
final ScrollController _scrollController = ScrollController();
|
|
bool _isAtTop = true;
|
|
bool _wasAtTopOnSwipeStart = true; // Capture at gesture start
|
|
|
|
// Transfer dropdown animation
|
|
late AnimationController _dropdownController;
|
|
bool _showingTransferDropdown = false;
|
|
|
|
// Drag state
|
|
int? _dragIndex;
|
|
int? _dragStartIndex;
|
|
double _dragY = 0; // Y position of dragged item relative to Stack
|
|
double _dragStartY = 0; // Y position when drag started (global)
|
|
double _dragOffsetInItem = 0; // Offset of touch point within item
|
|
QueueItem? _dragItem;
|
|
double _itemHeight = 64.0;
|
|
bool _pendingReorder = false; // True while waiting for API confirmation
|
|
Timer? _pendingReorderTimer;
|
|
|
|
// Optimistic update for tap-to-skip
|
|
int? _optimisticCurrentIndex;
|
|
|
|
// Swipe-to-close tracking (raw pointer events to bypass gesture arena)
|
|
Offset? _swipeStart;
|
|
Offset? _swipeLast;
|
|
int? _swipeLastTime; // milliseconds since epoch
|
|
bool _isSwiping = false;
|
|
bool _swipeLocked = false; // Lock direction once established
|
|
static const _swipeMinDistance = 8.0; // Min distance to start tracking (reduced for responsiveness)
|
|
static const _edgeDeadZone = 48.0; // Dead zone for Android back gesture (increased)
|
|
|
|
// Track last touch start position for edge detection in Dismissible
|
|
// Static so it persists across widget rebuilds
|
|
static double? _lastPointerDownX;
|
|
static double? _lastScreenWidth;
|
|
|
|
// Velocity tracking with multiple samples for smoother calculation
|
|
final List<_VelocitySample> _velocitySamples = [];
|
|
static const _maxVelocitySamples = 5;
|
|
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_items = List.from(widget.queue?.items ?? []);
|
|
_dropdownController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 300),
|
|
);
|
|
// Track scroll position for swipe-to-close (only allowed at top)
|
|
_scrollController.addListener(_onScroll);
|
|
}
|
|
|
|
void _onScroll() {
|
|
final atTop = _scrollController.offset <= 0;
|
|
if (atTop != _isAtTop) {
|
|
setState(() {
|
|
_isAtTop = atTop;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(QueuePanel oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
|
|
// Clear optimistic state when server catches up
|
|
if (_optimisticCurrentIndex != null) {
|
|
final serverIndex = widget.queue?.currentIndex;
|
|
if (serverIndex == _optimisticCurrentIndex) {
|
|
// Server caught up - clear optimistic state
|
|
_optimisticCurrentIndex = null;
|
|
}
|
|
}
|
|
|
|
// Don't update items while dragging or waiting for reorder confirmation
|
|
if (_dragIndex != null || _pendingReorder) return;
|
|
|
|
final newItems = widget.queue?.items ?? [];
|
|
|
|
// Only sync when player changes or item count changes (additions/deletions)
|
|
// Don't sync on order changes - trust our local order after user reorders
|
|
// This prevents stale server state from overwriting recent local changes
|
|
final playerChanged = widget.queue?.playerId != oldWidget.queue?.playerId;
|
|
final countChanged = newItems.length != _items.length;
|
|
|
|
if (playerChanged || countChanged) {
|
|
setState(() {
|
|
_items = List.from(newItems);
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.removeListener(_onScroll);
|
|
_scrollController.dispose();
|
|
_dropdownController.dispose();
|
|
_pendingReorderTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
|
|
void _handleDelete(QueueItem item, int index) async {
|
|
// Store item for potential rollback
|
|
final deletedItem = item;
|
|
final deletedIndex = index;
|
|
|
|
// Optimistic update - remove from local list
|
|
setState(() {
|
|
_items.removeAt(index);
|
|
});
|
|
|
|
// Call API with error handling
|
|
final playerId = widget.queue?.playerId;
|
|
if (playerId != null) {
|
|
try {
|
|
await widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId);
|
|
} catch (e) {
|
|
// Rollback: re-insert the item or refresh from server
|
|
// Refresh is safer as queue may have changed
|
|
widget.onRefresh();
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleClearQueue() async {
|
|
final playerId = widget.queue?.playerId;
|
|
if (playerId == null) return;
|
|
|
|
// Optimistic update - clear local list
|
|
setState(() {
|
|
_items.clear();
|
|
});
|
|
|
|
// Call API
|
|
try {
|
|
await widget.maProvider.api?.queueCommandClear(playerId);
|
|
} catch (e) {
|
|
// Refresh to restore if failed
|
|
widget.onRefresh();
|
|
}
|
|
}
|
|
|
|
void _openTransferDropdown() {
|
|
if (_showingTransferDropdown) return;
|
|
setState(() => _showingTransferDropdown = true);
|
|
_dropdownController.forward();
|
|
}
|
|
|
|
void _closeTransferDropdown() {
|
|
if (!_showingTransferDropdown) return;
|
|
_dropdownController.reverse().then((_) {
|
|
if (mounted) setState(() => _showingTransferDropdown = false);
|
|
});
|
|
}
|
|
|
|
void _handleTransferQueue(BuildContext context) {
|
|
if (_showingTransferDropdown) {
|
|
_closeTransferDropdown();
|
|
} else {
|
|
_openTransferDropdown();
|
|
}
|
|
}
|
|
|
|
List<Player> _getTargetPlayers() {
|
|
// Use selected player (current player) as source, not the queue's player ID
|
|
// This ensures correct filtering after queue transfer when player switches
|
|
final sourcePlayerId = widget.maProvider.selectedPlayer?.playerId;
|
|
if (sourcePlayerId == null) return [];
|
|
return widget.maProvider.availablePlayers
|
|
.where((p) => p.playerId != sourcePlayerId && p.available)
|
|
.toList();
|
|
}
|
|
|
|
Widget _buildTransferDropdown() {
|
|
final targetPlayers = _getTargetPlayers();
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
// Use a lighter shade of the queue panel background color
|
|
final hsl = HSLColor.fromColor(widget.backgroundColor);
|
|
final menuBackground = hsl.withLightness((hsl.lightness + 0.04).clamp(0.0, 1.0)).toColor();
|
|
// Use theme's onSurface for text (matches library dropdown styling)
|
|
final menuTextColor = colorScheme.onSurface;
|
|
|
|
return Container(
|
|
constraints: const BoxConstraints(maxHeight: 280, maxWidth: 220),
|
|
decoration: BoxDecoration(
|
|
color: menuBackground,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.3),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 6),
|
|
),
|
|
],
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(12),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Title header (matches library dropdown section headers)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: Text(
|
|
S.of(context)!.transferTo,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: menuTextColor.withOpacity(0.5),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Player list
|
|
if (targetPlayers.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
|
child: Text(
|
|
S.of(context)!.noOtherPlayersAvailable,
|
|
style: TextStyle(color: menuTextColor.withOpacity(0.5), fontSize: 14),
|
|
),
|
|
)
|
|
else
|
|
Flexible(
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
itemCount: targetPlayers.length,
|
|
itemBuilder: (context, index) {
|
|
final player = targetPlayers[index];
|
|
|
|
// Status color
|
|
Color statusColor;
|
|
if (!player.powered) {
|
|
statusColor = Colors.grey;
|
|
} else if (player.state == 'playing') {
|
|
statusColor = Colors.green;
|
|
} else {
|
|
statusColor = Colors.orange;
|
|
}
|
|
|
|
// Match library dropdown item styling
|
|
return InkWell(
|
|
onTap: () => _transferToPlayer(player),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.speaker_rounded, color: menuTextColor, size: 18),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
player.name,
|
|
style: TextStyle(color: menuTextColor),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: statusColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _transferToPlayer(Player targetPlayer) async {
|
|
final sourcePlayerId = widget.queue?.playerId;
|
|
if (sourcePlayerId == null) return;
|
|
|
|
// Close dropdown first
|
|
_closeTransferDropdown();
|
|
|
|
try {
|
|
await widget.maProvider.api?.transferQueue(
|
|
sourceQueueId: sourcePlayerId,
|
|
targetQueueId: targetPlayer.playerId,
|
|
autoPlay: true,
|
|
);
|
|
|
|
|
|
// Clear source player's track cache since its queue was transferred away
|
|
widget.maProvider.clearPlayerTrackCache(sourcePlayerId);
|
|
|
|
// Force collapse to mini player (closes queue instantly + collapses)
|
|
GlobalPlayerOverlay.forceCollapsePlayer();
|
|
|
|
// Switch to the target player - mini player will update to show new player
|
|
widget.maProvider.selectPlayer(targetPlayer);
|
|
} catch (e) {
|
|
// Error transferring queue - handled silently
|
|
}
|
|
}
|
|
|
|
void _resetSwipeState() {
|
|
_swipeStart = null;
|
|
_swipeLast = null;
|
|
_swipeLastTime = null;
|
|
_isSwiping = false;
|
|
_swipeLocked = false;
|
|
_velocitySamples.clear();
|
|
// NOTE: Don't reset _lastPointerDownX - it's needed by confirmDismiss
|
|
// which may be called after pointer up
|
|
}
|
|
|
|
/// Check if x position is in the edge dead zone (Android back gesture area)
|
|
static bool _isInEdgeZone(double x, double screenWidth) {
|
|
return x < _edgeDeadZone || x > screenWidth - _edgeDeadZone;
|
|
}
|
|
|
|
/// Check if last touch started in edge zone (for Dismissible to check)
|
|
static bool get lastTouchWasInEdgeZone {
|
|
if (_lastPointerDownX == null || _lastScreenWidth == null) return false;
|
|
return _isInEdgeZone(_lastPointerDownX!, _lastScreenWidth!);
|
|
}
|
|
|
|
/// Build an invisible edge absorber that blocks horizontal drags from triggering Dismissible
|
|
Widget _buildEdgeAbsorber({required bool left}) {
|
|
return Positioned(
|
|
left: left ? 0 : null,
|
|
right: left ? null : 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: _edgeDeadZone,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
onHorizontalDragStart: (_) {}, // Absorb horizontal drags
|
|
onHorizontalDragUpdate: (_) {},
|
|
onHorizontalDragEnd: (_) {},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _addVelocitySample(Offset position, int timeMs) {
|
|
_velocitySamples.add(_VelocitySample(position, timeMs));
|
|
if (_velocitySamples.length > _maxVelocitySamples) {
|
|
_velocitySamples.removeAt(0);
|
|
}
|
|
}
|
|
|
|
double _calculateAverageVelocity() {
|
|
if (_velocitySamples.length < 2) return 0.0;
|
|
|
|
// Use weighted average of recent samples (more recent = higher weight)
|
|
double totalVelocity = 0.0;
|
|
double totalWeight = 0.0;
|
|
|
|
for (int i = 1; i < _velocitySamples.length; i++) {
|
|
final prev = _velocitySamples[i - 1];
|
|
final curr = _velocitySamples[i];
|
|
final dt = curr.timeMs - prev.timeMs;
|
|
if (dt > 0 && dt < 100) { // Only use samples within 100ms
|
|
final dx = curr.position.dx - prev.position.dx;
|
|
final velocity = (dx / dt) * 1000; // px/s
|
|
final weight = i.toDouble(); // Later samples get higher weight
|
|
totalVelocity += velocity * weight;
|
|
totalWeight += weight;
|
|
}
|
|
}
|
|
|
|
return totalWeight > 0 ? totalVelocity / totalWeight : 0.0;
|
|
}
|
|
|
|
double _calculateAverageVelocityVertical() {
|
|
if (_velocitySamples.length < 2) return 0.0;
|
|
|
|
// Use weighted average of recent samples (more recent = higher weight)
|
|
double totalVelocity = 0.0;
|
|
double totalWeight = 0.0;
|
|
|
|
for (int i = 1; i < _velocitySamples.length; i++) {
|
|
final prev = _velocitySamples[i - 1];
|
|
final curr = _velocitySamples[i];
|
|
final dt = curr.timeMs - prev.timeMs;
|
|
if (dt > 0 && dt < 100) { // Only use samples within 100ms
|
|
final dy = curr.position.dy - prev.position.dy;
|
|
final velocity = (dy / dt) * 1000; // px/s
|
|
final weight = i.toDouble(); // Later samples get higher weight
|
|
totalVelocity += velocity * weight;
|
|
totalWeight += weight;
|
|
}
|
|
}
|
|
|
|
return totalWeight > 0 ? totalVelocity / totalWeight : 0.0;
|
|
}
|
|
|
|
void _startDrag(int index, BuildContext itemContext, Offset globalPosition) {
|
|
if (_dragIndex != null) return;
|
|
|
|
// Clear any pending swipe state to prevent conflicts
|
|
_resetSwipeState();
|
|
|
|
final RenderBox itemBox = itemContext.findRenderObject() as RenderBox;
|
|
final Offset itemGlobalPos = itemBox.localToGlobal(Offset.zero);
|
|
_itemHeight = itemBox.size.height;
|
|
|
|
// Get Stack's global position for proper coordinate conversion
|
|
final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?;
|
|
final stackGlobalPos = stackBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
|
|
|
// Calculate initial position relative to Stack
|
|
_dragY = itemGlobalPos.dy - stackGlobalPos.dy;
|
|
_dragStartY = globalPosition.dy;
|
|
// Remember where in the item the touch started (for smooth following)
|
|
_dragOffsetInItem = globalPosition.dy - itemGlobalPos.dy;
|
|
|
|
setState(() {
|
|
_dragIndex = index;
|
|
_dragStartIndex = index;
|
|
_dragItem = _items[index];
|
|
});
|
|
|
|
// Haptic feedback on drag start
|
|
HapticFeedback.mediumImpact();
|
|
|
|
// Notify parent that drag started
|
|
widget.onDraggingChanged?.call(true);
|
|
}
|
|
|
|
void _updateDragPointer(Offset globalPosition) {
|
|
if (_dragIndex == null) return;
|
|
|
|
// Get Stack's global position
|
|
final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?;
|
|
final stackGlobalPos = stackBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
|
|
|
// Calculate overlay position relative to Stack, accounting for touch offset
|
|
_dragY = globalPosition.dy - stackGlobalPos.dy - _dragOffsetInItem;
|
|
|
|
// Calculate which index we're hovering over based on movement from start
|
|
final totalOffset = globalPosition.dy - _dragStartY;
|
|
final indexOffset = (totalOffset / _itemHeight).round();
|
|
final targetIndex = (_dragStartIndex! + indexOffset).clamp(0, _items.length - 1);
|
|
|
|
if (targetIndex != _dragIndex) {
|
|
// Reorder items in the list
|
|
setState(() {
|
|
final item = _items.removeAt(_dragIndex!);
|
|
_items.insert(targetIndex, item);
|
|
_dragIndex = targetIndex;
|
|
});
|
|
// Haptic feedback on each reorder
|
|
HapticFeedback.selectionClick();
|
|
} else {
|
|
// Just update position
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
void _endDrag() async {
|
|
if (_dragIndex == null || _dragStartIndex == null) return;
|
|
|
|
final newIndex = _dragIndex!;
|
|
final originalIndex = _dragStartIndex!;
|
|
final item = _items[newIndex];
|
|
final positionChanged = originalIndex != newIndex;
|
|
|
|
// Cancel any existing timer to prevent stacking
|
|
_pendingReorderTimer?.cancel();
|
|
|
|
setState(() {
|
|
_dragIndex = null;
|
|
_dragStartIndex = null;
|
|
_dragItem = null;
|
|
// Block didUpdateWidget while waiting for server confirmation
|
|
if (positionChanged) _pendingReorder = true;
|
|
});
|
|
|
|
// Haptic feedback on drop
|
|
HapticFeedback.lightImpact();
|
|
|
|
// Notify parent that drag ended
|
|
widget.onDraggingChanged?.call(false);
|
|
|
|
// Call API if position changed
|
|
if (positionChanged) {
|
|
final playerId = widget.queue?.playerId;
|
|
// API uses relative shift: positive = down, negative = up
|
|
final posShift = newIndex - originalIndex;
|
|
if (playerId != null) {
|
|
try {
|
|
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift);
|
|
// Allow updates again after a delay for server state to propagate
|
|
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
|
|
if (mounted) {
|
|
setState(() {
|
|
_pendingReorder = false;
|
|
});
|
|
}
|
|
});
|
|
} catch (e) {
|
|
// Clear pending state immediately on error
|
|
if (mounted) {
|
|
setState(() {
|
|
_pendingReorder = false;
|
|
});
|
|
}
|
|
// Refresh queue from server to get correct state
|
|
widget.onRefresh();
|
|
}
|
|
} else {
|
|
// Clear pending state
|
|
if (mounted) {
|
|
setState(() {
|
|
_pendingReorder = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void _cancelDrag() {
|
|
if (_dragStartIndex == null) return;
|
|
|
|
// Restore original position
|
|
if (_dragIndex != null && _dragIndex != _dragStartIndex) {
|
|
setState(() {
|
|
final item = _items.removeAt(_dragIndex!);
|
|
_items.insert(_dragStartIndex!, item);
|
|
});
|
|
}
|
|
|
|
setState(() {
|
|
_dragIndex = null;
|
|
_dragStartIndex = null;
|
|
_dragItem = null;
|
|
});
|
|
|
|
// Notify parent that drag ended
|
|
widget.onDraggingChanged?.call(false);
|
|
}
|
|
|
|
void _handleTapToSkip(QueueItem item, int index, bool isCurrentItem) async {
|
|
if (isCurrentItem) return;
|
|
|
|
final playerId = widget.queue?.playerId;
|
|
if (playerId == null) return;
|
|
|
|
// Optimistic update: immediately show tapped track as current
|
|
setState(() {
|
|
_optimisticCurrentIndex = index;
|
|
});
|
|
|
|
// Call API
|
|
try {
|
|
await widget.maProvider.api?.queueCommandPlayIndex(playerId, item.queueItemId);
|
|
} catch (e) {
|
|
// Error playing index - clear optimistic state
|
|
if (mounted) {
|
|
setState(() {
|
|
_optimisticCurrentIndex = null;
|
|
});
|
|
}
|
|
}
|
|
// Note: didUpdateWidget clears _optimisticCurrentIndex when server state catches up
|
|
}
|
|
|
|
void _handleMoveToPlayNext(QueueItem item, int index, bool isCurrentItem) async {
|
|
if (isCurrentItem) return;
|
|
|
|
final playerId = widget.queue?.playerId;
|
|
if (playerId == null) return;
|
|
|
|
// Get current index (where the currently playing track is)
|
|
final currentIndex = _optimisticCurrentIndex ?? widget.queue!.currentIndex ?? 0;
|
|
|
|
// Target position is right after the current track
|
|
final targetIndex = currentIndex + 1;
|
|
|
|
// If already at target position, nothing to do
|
|
if (index == targetIndex) {
|
|
// Haptic to confirm it's already in position
|
|
HapticFeedback.lightImpact();
|
|
return;
|
|
}
|
|
|
|
// Calculate the position shift for the API
|
|
final posShift = targetIndex - index;
|
|
|
|
// Cancel any existing timer to prevent stacking
|
|
_pendingReorderTimer?.cancel();
|
|
|
|
// Optimistic update - move item in local list
|
|
setState(() {
|
|
final movedItem = _items.removeAt(index);
|
|
_items.insert(targetIndex > index ? targetIndex - 1 : targetIndex, movedItem);
|
|
_pendingReorder = true;
|
|
});
|
|
|
|
// Haptic feedback
|
|
HapticFeedback.mediumImpact();
|
|
|
|
// Call API
|
|
try {
|
|
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift);
|
|
// Allow updates again after a delay for server state to propagate
|
|
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
|
|
if (mounted) {
|
|
setState(() {
|
|
_pendingReorder = false;
|
|
});
|
|
}
|
|
});
|
|
} catch (e) {
|
|
// Clear pending state immediately on error
|
|
if (mounted) {
|
|
setState(() {
|
|
_pendingReorder = false;
|
|
});
|
|
}
|
|
// Refresh queue from server to get correct state
|
|
widget.onRefresh();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Use Listener for raw pointer events to bypass gesture arena
|
|
// This allows swipe-to-close to work even over ListView/Dismissible
|
|
return Listener(
|
|
onPointerDown: (event) {
|
|
// Store touch position for edge detection (used by Dismissible confirmDismiss)
|
|
// Use static variables so they persist across rebuilds
|
|
_lastPointerDownX = event.position.dx;
|
|
_lastScreenWidth = MediaQuery.of(context).size.width;
|
|
|
|
// Check if touch started in edge zone (Android back gesture area)
|
|
final startedInEdgeZone = _isInEdgeZone(event.position.dx, _lastScreenWidth!);
|
|
|
|
// Don't track swipe while dragging a queue item
|
|
if (_dragIndex == null && !startedInEdgeZone) {
|
|
_swipeStart = event.position;
|
|
_swipeLast = event.position;
|
|
_swipeLastTime = DateTime.now().millisecondsSinceEpoch;
|
|
_isSwiping = false;
|
|
_swipeLocked = false;
|
|
_velocitySamples.clear();
|
|
_addVelocitySample(event.position, _swipeLastTime!);
|
|
// Capture scroll position at gesture start - only allow close if already at top
|
|
_wasAtTopOnSwipeStart = _isAtTop;
|
|
} else if (startedInEdgeZone) {
|
|
// Clear swipe state for edge touches
|
|
_swipeStart = null;
|
|
}
|
|
},
|
|
onPointerMove: (event) {
|
|
// Ignore swipes while dragging a queue item
|
|
if (_swipeStart == null || _dragIndex != null) return;
|
|
|
|
final dx = (event.position.dx - _swipeStart!.dx).abs();
|
|
final dy = event.position.dy - _swipeStart!.dy;
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
|
|
// Track all move events for velocity calculation
|
|
_addVelocitySample(event.position, now);
|
|
_swipeLast = event.position;
|
|
_swipeLastTime = now;
|
|
|
|
// Once direction is locked, maintain it (prevents accidental cancellation)
|
|
if (_swipeLocked && _isSwiping) {
|
|
widget.onSwipeUpdate?.call(dy.clamp(0.0, double.infinity));
|
|
return;
|
|
}
|
|
|
|
// Check if this is a vertical swipe (dy > dx * 1.2)
|
|
final isVertical = dy.abs() > _swipeMinDistance && dy.abs() > dx * 1.2;
|
|
|
|
if (isVertical && dy > 0 && _wasAtTopOnSwipeStart) {
|
|
// Vertical swipe down - close gesture (only when was at top when gesture started)
|
|
if (!_isSwiping) {
|
|
_isSwiping = true;
|
|
_swipeLocked = true; // Lock direction once swipe starts
|
|
widget.onSwipeStart?.call();
|
|
}
|
|
widget.onSwipeUpdate?.call(dy);
|
|
} else if (!_swipeLocked && dy.abs() > _swipeMinDistance) {
|
|
// Direction established as horizontal - don't start swipe
|
|
_swipeLocked = true; // Lock as non-swipe
|
|
}
|
|
},
|
|
onPointerUp: (event) {
|
|
if (_isSwiping && _swipeStart != null) {
|
|
final velocity = _calculateAverageVelocityVertical();
|
|
final totalDy = event.position.dy - _swipeStart!.dy;
|
|
widget.onSwipeEnd?.call(velocity, totalDy);
|
|
}
|
|
_resetSwipeState();
|
|
},
|
|
onPointerCancel: (_) {
|
|
if (_isSwiping) {
|
|
widget.onSwipeEnd?.call(0, 0); // Cancel - no action
|
|
}
|
|
_resetSwipeState();
|
|
},
|
|
child: Container(
|
|
color: widget.backgroundColor,
|
|
child: Stack(
|
|
children: [
|
|
// Main content column
|
|
Column(
|
|
children: [
|
|
// Header
|
|
Container(
|
|
padding: EdgeInsets.only(top: widget.topPadding + 4, left: 4, right: 16),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: Icon(Icons.keyboard_arrow_down_rounded, color: widget.textColor, size: IconSizes.lg),
|
|
onPressed: _showingTransferDropdown ? _closeTransferDropdown : widget.onClose,
|
|
padding: Spacing.paddingAll12,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Queue',
|
|
style: TextStyle(
|
|
color: widget.textColor,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
// Transfer queue button
|
|
IconButton(
|
|
icon: Icon(
|
|
_showingTransferDropdown ? Icons.close : Icons.swap_horiz_rounded,
|
|
color: _showingTransferDropdown ? widget.primaryColor : widget.textColor.withOpacity(0.7),
|
|
size: IconSizes.sm,
|
|
),
|
|
onPressed: () => _handleTransferQueue(context),
|
|
padding: Spacing.paddingAll12,
|
|
),
|
|
// Clear queue button
|
|
IconButton(
|
|
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
|
|
onPressed: _handleClearQueue,
|
|
padding: Spacing.paddingAll12,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Queue content
|
|
Expanded(
|
|
child: widget.isLoading
|
|
? Center(child: CircularProgressIndicator(color: widget.primaryColor))
|
|
: widget.queue == null || _items.isEmpty
|
|
? EmptyState.queue(context: context)
|
|
: Listener(
|
|
// Capture pointer events anywhere while dragging
|
|
onPointerMove: (event) {
|
|
if (_dragIndex != null) {
|
|
_updateDragPointer(event.position);
|
|
}
|
|
},
|
|
onPointerUp: (event) {
|
|
if (_dragIndex != null) {
|
|
_endDrag();
|
|
}
|
|
},
|
|
onPointerCancel: (event) {
|
|
if (_dragIndex != null) {
|
|
_cancelDrag();
|
|
}
|
|
},
|
|
child: Stack(
|
|
key: _stackKey,
|
|
children: [
|
|
_buildQueueList(),
|
|
// Dragged item overlay
|
|
if (_dragIndex != null && _dragItem != null)
|
|
Positioned(
|
|
left: 8,
|
|
right: 8,
|
|
top: _dragY,
|
|
child: Material(
|
|
elevation: 8,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: _buildQueueItemContent(_dragItem!, _dragIndex!, false, false),
|
|
),
|
|
),
|
|
// Edge gesture absorbers - block horizontal drags from screen edges
|
|
// This prevents Android back gesture from triggering Dismissible
|
|
_buildEdgeAbsorber(left: true),
|
|
_buildEdgeAbsorber(left: false),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
// Floating dropdown overlay for transfer
|
|
if (_showingTransferDropdown) ...[
|
|
// Tap-to-dismiss backdrop
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
onTap: _closeTransferDropdown,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Container(color: Colors.transparent),
|
|
),
|
|
),
|
|
// Dropdown menu with open/close animation
|
|
Positioned(
|
|
top: widget.topPadding + 52, // Below header
|
|
right: 56, // Aligned with transfer button
|
|
child: AnimatedBuilder(
|
|
animation: CurvedAnimation(
|
|
parent: _dropdownController,
|
|
curve: Curves.easeInOut,
|
|
),
|
|
builder: (context, child) {
|
|
final value = _dropdownController.value;
|
|
final curvedValue = Curves.easeInOut.transform(value);
|
|
|
|
return ClipRect(
|
|
child: Align(
|
|
alignment: Alignment.topCenter,
|
|
widthFactor: 1.0,
|
|
heightFactor: curvedValue,
|
|
child: Opacity(
|
|
opacity: curvedValue,
|
|
child: child,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
child: _buildTransferDropdown(),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildQueueList() {
|
|
// Use optimistic index if set, otherwise use server state
|
|
final currentIndex = _optimisticCurrentIndex ?? widget.queue!.currentIndex ?? 0;
|
|
|
|
return ListView.builder(
|
|
key: const PageStorageKey('queue_list'),
|
|
controller: _scrollController,
|
|
padding: Spacing.paddingH8,
|
|
// Disable scrolling while dragging to prevent gesture conflict
|
|
physics: _dragIndex != null
|
|
? const NeverScrollableScrollPhysics()
|
|
: const AlwaysScrollableScrollPhysics(),
|
|
itemCount: _items.length,
|
|
itemBuilder: (context, index) {
|
|
final item = _items[index];
|
|
final isCurrentItem = index == currentIndex;
|
|
final isPastItem = index < currentIndex;
|
|
final isDragging = _dragIndex == index;
|
|
|
|
return Opacity(
|
|
opacity: isDragging ? 0.3 : 1.0,
|
|
child: _buildDismissibleItem(item, index, isCurrentItem, isPastItem),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildDismissibleItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem) {
|
|
return Dismissible(
|
|
key: ValueKey(item.queueItemId),
|
|
direction: isCurrentItem ? DismissDirection.none : DismissDirection.horizontal,
|
|
// Swipe right (startToEnd) = move to play next
|
|
background: Container(
|
|
alignment: Alignment.centerLeft,
|
|
padding: const EdgeInsets.only(left: 20),
|
|
decoration: BoxDecoration(
|
|
color: widget.primaryColor,
|
|
borderRadius: BorderRadius.zero,
|
|
),
|
|
child: const Icon(Icons.skip_next_rounded, color: Colors.white, size: 28),
|
|
),
|
|
// Swipe left (endToStart) = delete
|
|
secondaryBackground: Container(
|
|
alignment: Alignment.centerRight,
|
|
padding: const EdgeInsets.only(right: 20),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.shade700,
|
|
borderRadius: BorderRadius.zero,
|
|
),
|
|
child: const Icon(Icons.delete_outline, color: Colors.white, size: 24),
|
|
),
|
|
confirmDismiss: (direction) async {
|
|
// Block dismissal if swipe started from screen edge (Android back gesture)
|
|
if (lastTouchWasInEdgeZone) return false;
|
|
|
|
if (direction == DismissDirection.startToEnd) {
|
|
// Swipe right = move to play next position
|
|
_handleMoveToPlayNext(item, index, isCurrentItem);
|
|
return false; // Don't dismiss - item stays in queue (just moved)
|
|
} else {
|
|
// Swipe left = delete
|
|
return true;
|
|
}
|
|
},
|
|
onDismissed: (direction) => _handleDelete(item, index),
|
|
child: _buildQueueItemWithDragHandle(item, index, isCurrentItem, isPastItem),
|
|
);
|
|
}
|
|
|
|
Widget _buildQueueItemWithDragHandle(QueueItem item, int index, bool isCurrentItem, bool isPastItem) {
|
|
return Builder(
|
|
builder: (itemContext) => _buildQueueItemContent(
|
|
item,
|
|
index,
|
|
isCurrentItem,
|
|
isPastItem,
|
|
dragHandle: isCurrentItem
|
|
? SizedBox(
|
|
width: 48,
|
|
height: 48,
|
|
child: Center(
|
|
child: Icon(Icons.play_arrow_rounded, color: widget.primaryColor, size: 20),
|
|
),
|
|
)
|
|
: SizedBox(
|
|
width: 48,
|
|
height: 48,
|
|
child: Listener(
|
|
behavior: HitTestBehavior.opaque,
|
|
onPointerDown: (event) {
|
|
_startDrag(index, itemContext, event.position);
|
|
},
|
|
// Move/up/cancel handled by parent Listener on Stack
|
|
child: Center(
|
|
child: Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildQueueItemContent(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {Widget? dragHandle}) {
|
|
final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80);
|
|
final duration = formatDurationOrEmpty(item.track.duration);
|
|
|
|
return RepaintBoundary(
|
|
child: Opacity(
|
|
opacity: isPastItem ? 0.5 : 1.0,
|
|
child: Container(
|
|
margin: isCurrentItem ? const EdgeInsets.symmetric(horizontal: 8, vertical: 2) : EdgeInsets.zero,
|
|
decoration: BoxDecoration(
|
|
color: isCurrentItem ? widget.primaryColor.withOpacity(0.15) : widget.backgroundColor,
|
|
borderRadius: isCurrentItem ? BorderRadius.circular(12) : BorderRadius.zero,
|
|
),
|
|
child: ListTile(
|
|
dense: true,
|
|
// Compensate for current item's 8px margin on both sides
|
|
// Left: Non-current: 8+0+16=24px, Current: 8+8+8=24px
|
|
// Right: Non-current: 8+0+8+14=30px, Current: 8+8+0+14=30px
|
|
contentPadding: EdgeInsets.only(
|
|
left: isCurrentItem ? 8 : 16,
|
|
right: isCurrentItem ? 0 : 8,
|
|
),
|
|
leading: ClipRRect(
|
|
borderRadius: BorderRadius.circular(Radii.sm),
|
|
child: SizedBox(
|
|
width: 44,
|
|
height: 44,
|
|
child: imageUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: imageUrl,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: 176,
|
|
memCacheHeight: 176,
|
|
fadeInDuration: Duration.zero,
|
|
fadeOutDuration: Duration.zero,
|
|
placeholder: (context, url) => Container(
|
|
color: widget.textColor.withOpacity(0.1),
|
|
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
|
|
),
|
|
errorWidget: (context, url, error) => Container(
|
|
color: widget.textColor.withOpacity(0.1),
|
|
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
|
|
),
|
|
)
|
|
: Container(
|
|
color: widget.textColor.withOpacity(0.1),
|
|
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
|
|
),
|
|
),
|
|
),
|
|
title: Text(
|
|
item.track.name,
|
|
style: TextStyle(
|
|
color: isCurrentItem ? widget.primaryColor : widget.textColor,
|
|
fontSize: 14,
|
|
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
|
|
? Text(
|
|
item.track.artists!.first.name,
|
|
style: TextStyle(
|
|
color: widget.textColor.withOpacity(0.6),
|
|
fontSize: 12,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
)
|
|
: null,
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (duration.isNotEmpty)
|
|
Text(
|
|
duration,
|
|
style: TextStyle(
|
|
color: widget.textColor.withOpacity(0.5),
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
dragHandle ?? Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20),
|
|
],
|
|
),
|
|
onTap: () => _handleTapToSkip(item, index, isCurrentItem),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Helper class for velocity tracking samples
|
|
class _VelocitySample {
|
|
final Offset position;
|
|
final int timeMs;
|
|
|
|
_VelocitySample(this.position, this.timeMs);
|
|
}
|
|
|