Files
plezy/lib/screens/music/queue_sheet.dart
T
edde746 214a7c13dc feat(music): show full play queue with history, YouTube Music style
Queue sheet/panel now renders the whole playback order — dimmed played
tracks above the current one — instead of only up-next. Opens scrolled
to the current track and follows advancement while parked on it.
2026-07-06 11:30:23 +02:00

249 lines
9.5 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/input_mode_tracker.dart';
import '../../i18n/strings.g.dart';
import '../../services/music/music_playback_service.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/platform_detector.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/bottom_sheet_header.dart';
import '../../widgets/music/repeat_mode.dart';
import '../../widgets/music/track_row.dart';
import '../../widgets/overlay_sheet.dart';
/// Open the play-queue sheet. The caller's screen must have an
/// [OverlaySheetHost] ancestor (all now-playing layouts do) so TV back
/// handling stays centralized in the host.
Future<void> showQueueSheet(BuildContext context) {
return OverlaySheetController.showAdaptive<void>(context, showDragHandle: true, builder: (_) => const QueueSheet());
}
/// Sheet chrome around [QueueList]: header with track count, shuffle/repeat
/// toggles mirroring the service state, and Clear (upcoming) action.
class QueueSheet extends StatelessWidget {
const QueueSheet({super.key});
@override
Widget build(BuildContext context) {
final tk = tokens(context);
final service = context.watch<MusicPlaybackService>();
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: .min,
children: [
BottomSheetHeader(
title: t.music.queue,
action: Row(
mainAxisSize: .min,
children: [
Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(
t.music.trackCount(n: service.queue.length),
style: TextStyle(fontSize: 13, color: tk.textMuted),
),
),
IconButton(
icon: AppIcon(
Symbols.shuffle_rounded,
fill: 1,
size: 20,
color: service.shuffled ? colorScheme.primary : tk.textMuted,
),
tooltip: t.common.shuffle,
onPressed: service.toggleShuffle,
),
IconButton(
icon: AppIcon(
repeatModeIcon(service.repeatMode),
fill: 1,
size: 20,
color: service.repeatMode == MusicRepeatMode.off ? tk.textMuted : colorScheme.primary,
),
tooltip: repeatModeLabel(service.repeatMode),
onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)),
),
IconButton(
icon: AppIcon(Symbols.clear_all_rounded, fill: 1, size: 20, color: tk.textMuted),
tooltip: t.music.clearQueue,
onPressed: service.clearUpcoming,
),
],
),
),
const Flexible(child: QueueList(autofocusCurrent: true)),
],
);
}
}
/// The queue body: the full play queue in playback order — history, the
/// current track (equalizer row, dimmed rows above are already played),
/// then upcoming — YouTube-Music style. Opens scrolled so the current track
/// sits at the top, and follows track changes while the user is near the
/// playback position. Reused verbatim by the desktop now-playing layout as
/// an inline panel.
///
/// Touch: long-press drag to reorder (delayed drag listener — TrackRow's
/// context menu is disabled here so the gesture arena stays clean), swipe to
/// remove, tap to jump. D-pad: rows are focusable, SELECT jumps, RIGHT
/// reaches a dedicated remove button; reordering is intentionally not
/// offered on TV. With [autofocusCurrent] (the sheet) initial focus lands on
/// the current track's row instead of the first history row.
class QueueList extends StatefulWidget {
/// Land initial d-pad/keyboard focus on the current track's row,
/// overriding the overlay host's focus-first-descendant default. The
/// inline desktop panel keeps this off so building the now-playing screen
/// never steals focus.
final bool autofocusCurrent;
const QueueList({super.key, this.autofocusCurrent = false});
@override
State<QueueList> createState() => _QueueListState();
}
class _QueueListState extends State<QueueList> {
ScrollController? _scrollController;
final _currentRowFocusNode = FocusNode(debugLabel: 'QueueCurrentRowFocus');
int _lastCurrentIndex = -1;
/// Content offset that puts the row at [index] at the top of the viewport
/// (its top gap scrolled away, the list's own padding still visible).
/// Rows are fixed-extent, so this is exact — the shared
/// measure-the-first-item scroll helper can't be used because only the
/// first row lacks a leading gap.
double _rowOffset(int index, double gap) => index * (TrackRow.height + gap);
@override
void initState() {
super.initState();
if (widget.autofocusCurrent) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Timer.run(() {
if (!mounted || !InputModeTracker.isKeyboardMode(context)) return;
// Schedule after the overlay host's _autoFocus second callback so
// we override its focus-first-descendant default.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_currentRowFocusNode.context != null) _currentRowFocusNode.requestFocus();
});
});
});
}
}
@override
void dispose() {
_scrollController?.dispose();
_currentRowFocusNode.dispose();
super.dispose();
}
/// Keep the current track at the top while playback advances, but only
/// when the list is still parked where the previous track was anchored —
/// once the user scrolls away to browse, moving rows under their pointer
/// invites mis-taps on remove/jump.
void _followCurrentTrack(int currentIndex, double gap) {
if (currentIndex == _lastCurrentIndex) return;
final fromIndex = _lastCurrentIndex;
_lastCurrentIndex = currentIndex;
// First build: the initial scroll offset already targets the row.
if (fromIndex < 0 || currentIndex < 0) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
final controller = _scrollController;
if (!mounted || controller == null || !controller.hasClients) return;
final position = controller.position;
final anchor = _rowOffset(fromIndex, gap).clamp(0.0, position.maxScrollExtent);
if ((anchor - position.pixels).abs() > (TrackRow.height + gap) * 1.5) return;
final target = _rowOffset(currentIndex, gap).clamp(0.0, position.maxScrollExtent);
unawaited(controller.animateTo(target, duration: const Duration(milliseconds: 250), curve: Curves.easeOut));
});
}
@override
Widget build(BuildContext context) {
final tk = tokens(context);
final service = context.watch<MusicPlaybackService>();
final queue = service.queue;
final currentIndex = service.currentIndex;
if (queue.isEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Text(t.messages.noItemsAvailable, style: TextStyle(fontSize: 13, color: tk.textMuted)),
);
}
_scrollController ??= ScrollController(initialScrollOffset: _rowOffset(currentIndex, tk.groupGap));
_followCurrentTrack(currentIndex, tk.groupGap);
// Stable per-item keys that survive reorders; the same track can appear
// more than once, so disambiguate repeats by occurrence.
final seen = <String, int>{};
final itemKeys = [
for (final item in queue) '${item.globalKey}#${seen.update(item.globalKey, (v) => v + 1, ifAbsent: () => 0)}',
];
final allowTouchEditing = !PlatformDetector.isTV();
return ReorderableListView.builder(
scrollController: _scrollController,
shrinkWrap: true,
buildDefaultDragHandles: false,
padding: const EdgeInsets.all(12),
itemCount: queue.length,
// onReorderItem already adjusts newIndex for the removal.
onReorderItem: (oldIndex, newIndex) {
if (oldIndex == newIndex) return;
service.reorder(oldIndex, newIndex);
},
itemBuilder: (context, index) {
final item = queue[index];
final isCurrent = index == currentIndex;
Widget row = TrackRow(
item: item,
showArtist: true,
isFirst: index == 0,
isLast: index == queue.length - 1,
enableContextMenu: false,
isCurrent: isCurrent,
dimmed: index < currentIndex,
focusNode: isCurrent ? _currentRowFocusNode : null,
trailingIcon: Symbols.close_rounded,
onTrailingTap: () => service.removeAt(index),
onTap: () => unawaited(service.jumpTo(index)),
);
if (allowTouchEditing) {
row = ReorderableDelayedDragStartListener(index: index, child: row);
row = Dismissible(
key: ValueKey('dismiss:${itemKeys[index]}'),
direction: DismissDirection.endToStart,
background: Container(
alignment: .centerRight,
padding: const EdgeInsets.only(right: 20),
color: Colors.red,
child: const AppIcon(Symbols.delete_rounded, fill: 1, color: Colors.white, size: 20),
),
onDismissed: (_) => service.removeAt(index),
child: row,
);
}
return Padding(
key: ValueKey(itemKeys[index]),
padding: EdgeInsets.only(top: index == 0 ? 0 : tk.groupGap),
child: row,
);
},
);
}
}