import 'dart:async'; import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:freezer/api/definitions.dart'; import 'package:freezer/api/player/audio_handler.dart'; import 'package:freezer/translations.i18n.dart'; import 'package:freezer/ui/menu.dart'; import 'package:freezer/ui/tiles.dart'; class QueueScreen extends StatelessWidget { const QueueScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Queue'.i18n), systemOverlayStyle: SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.light, statusBarBrightness: Brightness.light, systemNavigationBarColor: Theme.of(context).scaffoldBackgroundColor, systemNavigationBarDividerColor: Color( Theme.of(context).scaffoldBackgroundColor.value - 0x00111111), systemNavigationBarIconBrightness: Brightness.light, ), // actions: [ // IconButton( // icon: Icon( // Icons.shuffle, // semanticLabel: "Shuffle".i18n, // ), // onPressed: () async { // await playerHelper.toggleShuffle(); // setState(() {}); // }, // ) // ], ), body: const SafeArea(child: QueueListWidget(shouldPopOnTap: true))); } } class QueueListWidget extends StatefulWidget { final bool shouldPopOnTap; const QueueListWidget({super.key, this.shouldPopOnTap = false}); @override State createState() => _QueueListWidgetState(); } class _QueueListWidgetState extends State { static const itemExtent = 68.0; // height of each TrackTile final _scrollController = ScrollController(); late StreamSubscription _queueSub; static const _dismissibleBackground = DecoratedBox( decoration: BoxDecoration(color: Colors.red), child: Align( alignment: Alignment.centerLeft, child: Padding( padding: EdgeInsets.symmetric(horizontal: 24.0), child: Icon(Icons.delete)))); static const _dismissibleSecondaryBackground = DecoratedBox( decoration: BoxDecoration(color: Colors.red), child: Align( alignment: Alignment.centerRight, child: Padding( padding: EdgeInsets.symmetric(horizontal: 24.0), child: Icon(Icons.delete)))); bool _isReordering = false; /// Basically a simple list that keeps itself synchronized with [AudioHandler.queue], /// so that the [ReorderableListView] is updated instanly (as it should be) List _queueCache = []; @override void initState() { _queueCache = List.from(audioHandler.queue.value); // avoid shadow-copying _queueSub = audioHandler.queue.listen((newQueue) { print('got new queue!'); // if (listEquals(_queueCache, newQueue)) { // print('avoiding rebuilding queue since they are the same'); // return; // } _queueCache = List.from(newQueue); setState(() {}); }); WidgetsBinding.instance.addPostFrameCallback((_) { // calculate position of current item double position = min(playerHelper.queueIndex * itemExtent, _scrollController.position.maxScrollExtent); _scrollController.jumpTo(position); }); super.initState(); } @override void dispose() { _queueSub.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return ReorderableListView.builder( buildDefaultDragHandles: false, scrollController: _scrollController, // specify the itemExtent normally and remove it when reordering because of an issue with [SliverFixedExtentList] // https://github.com/flutter/flutter/issues/84901 itemExtent: _isReordering ? null : itemExtent, onReorderStart: (_) => setState(() => _isReordering = true), onReorderEnd: (_) => setState(() => _isReordering = true), onReorder: (oldIndex, newIndex) { setState(() => _queueCache.reorder(oldIndex, newIndex)); if (oldIndex == playerHelper.queueIndex) { audioHandler.customAction('setIndex', {'index': newIndex}); } playerHelper.reorder(oldIndex, newIndex); }, itemCount: _queueCache.length, itemBuilder: (BuildContext context, int index) { final mediaItem = _queueCache[index]; final int itemId = mediaItem.extras!['id'] ?? 0; return Dismissible( key: ValueKey(mediaItem.id.hashCode | itemId), background: _dismissibleBackground, secondaryBackground: _dismissibleSecondaryBackground, onDismissed: (_) { audioHandler.removeQueueItemAt(index).then((value) { if (index == playerHelper.queueIndex) { audioHandler.skipToNext(); } }); setState(() => _queueCache.removeAt(index)); }, confirmDismiss: (_) { final completer = Completer(); ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context) .showSnackBar(SnackBar( behavior: SnackBarBehavior.floating, content: Text('Song deleted from queue'.i18n), action: SnackBarAction( label: 'UNDO'.i18n, onPressed: () => completer.complete(false)))) .closed .then((value) { if (value == SnackBarClosedReason.action) return; completer.complete(true); }); return completer.future; }, child: SizedBox( height: itemExtent, child: TrackTile.fromMediaItem( mediaItem, trailing: ReorderableDragStartListener( index: index, child: const Icon(Icons.drag_handle)), onTap: () { audioHandler.skipToQueueItem(index).then((value) { if (widget.shouldPopOnTap) { Navigator.of(context).pop(); } }); }, onSecondary: (details) => MenuSheet(context).defaultTrackMenu( Track.fromMediaItem(mediaItem), details: details), checkTrackOffline: false, ), ), ); }, ); } }