diff --git a/lib/player/now_playing_screen.dart b/lib/player/now_playing_screen.dart index 55764ec..2ebf653 100644 --- a/lib/player/now_playing_screen.dart +++ b/lib/player/now_playing_screen.dart @@ -6,6 +6,7 @@ import '../shared/cover.dart'; import '../shared/favorite_button.dart'; import '../shared/theme.dart'; import 'audio_handler.dart'; +import 'queue_screen.dart'; /// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls. class NowPlayingScreen extends StatelessWidget { @@ -27,6 +28,14 @@ class NowPlayingScreen extends StatelessWidget { }, ), _SleepTimerButton(handler: handler), + IconButton( + tooltip: 'Warteschlange', + icon: const Icon(Icons.queue_music), + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const QueueScreen()), + ), + ), ], ), body: SafeArea( diff --git a/lib/player/queue_screen.dart b/lib/player/queue_screen.dart new file mode 100644 index 0000000..4d04dea --- /dev/null +++ b/lib/player/queue_screen.dart @@ -0,0 +1,137 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../shared/cover.dart'; +import '../shared/theme.dart'; +import 'audio_handler.dart'; + +/// Warteschlangen-Ansicht: aktuelle Queue anzeigen, zu einem Titel springen, +/// Titel entfernen oder per Drag & Drop umsortieren. +/// +/// Der `MeloAudioHandler` bietet keine Live-Queue-Mutation (kein +/// `removeQueueItem`/`moveQueueItem`), daher wird die Queue clientseitig neu +/// berechnet und per `loadPlaylist` neu geladen. +class QueueScreen extends StatelessWidget { + const QueueScreen({super.key}); + + @override + Widget build(BuildContext context) { + final handler = context.read(); + return Scaffold( + appBar: AppBar(title: const Text('Warteschlange')), + body: StreamBuilder>( + stream: handler.queue, + builder: (context, queueSnap) { + final queue = queueSnap.data ?? const []; + if (queue.isEmpty) { + return const Center( + child: Text('Warteschlange ist leer', + style: TextStyle(color: Colors.white54)), + ); + } + return StreamBuilder( + stream: handler.mediaItem, + builder: (context, currentSnap) { + final currentId = currentSnap.data?.id; + return ReorderableListView.builder( + itemCount: queue.length, + onReorderItem: (oldIndex, newIndex) => + _reorder(handler, oldIndex, newIndex), + itemBuilder: (context, i) { + final item = queue[i]; + final playing = item.id == currentId; + return ListTile( + key: ValueKey(item.id), + leading: CoverImage(artUri: item.artUri, size: 48, radius: 6), + title: Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: playing + ? const TextStyle( + color: MeloTheme.red, fontWeight: FontWeight.bold) + : null, + ), + subtitle: Text(item.artist ?? 'Unbekannt', + maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: IconButton( + tooltip: 'Aus Warteschlange entfernen', + icon: const Icon(Icons.close), + onPressed: () => _remove(handler, i), + ), + onTap: () => handler.skipToQueueItem(i), + ); + }, + ); + }, + ); + }, + ), + ); + } + + Future _remove(MeloAudioHandler handler, int index) async { + final queue = handler.queue.valueOrNull ?? const []; + if (index < 0 || index >= queue.length) return; + final playingId = handler.mediaItem.valueOrNull?.id; + final position = await handler.positionStream.first; + + final newQueue = removeFromQueue(queue, index); + if (newQueue.isEmpty) { + await handler.stop(); + return; + } + final fallbackIndex = index.clamp(0, newQueue.length - 1); + final newIndex = newPlayingIndex(newQueue, playingId, fallbackIndex); + await handler.loadPlaylist(newQueue, startIndex: newIndex); + if (newQueue[newIndex].id == playingId) { + await handler.seek(position); + } + } + + Future _reorder( + MeloAudioHandler handler, int oldIndex, int newIndex) async { + final queue = handler.queue.valueOrNull ?? const []; + if (queue.isEmpty) return; + final playingId = handler.mediaItem.valueOrNull?.id; + final position = await handler.positionStream.first; + + final newQueue = reorderQueue(queue, oldIndex, newIndex); + final fallbackIndex = newIndex.clamp(0, newQueue.length - 1); + final newPlayIndex = newPlayingIndex(newQueue, playingId, fallbackIndex); + await handler.loadPlaylist(newQueue, startIndex: newPlayIndex); + if (newQueue[newPlayIndex].id == playingId) { + await handler.seek(position); + } + } +} + +// === Reine Queue-Logik (ohne AudioHandler testbar) === + +/// Entfernt den Titel an [removeIndex] aus [queue]. +List removeFromQueue(List queue, int removeIndex) { + return List.from(queue)..removeAt(removeIndex); +} + +/// Verschiebt den Titel von [oldIndex] nach [newIndex]. `newIndex` ist bereits +/// um das entfernte Element bereinigt (siehe `ReorderableListView.onReorderItem`). +List reorderQueue(List queue, int oldIndex, int newIndex) { + final result = List.from(queue); + final moved = result.removeAt(oldIndex); + result.insert(newIndex, moved); + return result; +} + +/// Ermittelt den Index des zuvor abgespielten Titels ([playingId]) in +/// [newQueue]. Ist er nicht mehr vorhanden, wird auf [fallbackIndex] +/// zurückgefallen (z.B. der Titel, der jetzt an seiner alten Position steht). +int newPlayingIndex( + List newQueue, String? playingId, int fallbackIndex) { + if (newQueue.isEmpty) return 0; + if (playingId != null) { + final idx = newQueue.indexWhere((item) => item.id == playingId); + if (idx != -1) return idx; + } + return fallbackIndex.clamp(0, newQueue.length - 1); +} diff --git a/test/player/queue_screen_test.dart b/test/player/queue_screen_test.dart new file mode 100644 index 0000000..0eb9272 --- /dev/null +++ b/test/player/queue_screen_test.dart @@ -0,0 +1,85 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/player/queue_screen.dart'; + +MediaItem _item(String id) => MediaItem(id: id, title: 'Titel $id'); + +void main() { + group('removeFromQueue', () { + test('entfernt den Titel am angegebenen Index und verschiebt den Rest', () { + final queue = [_item('a'), _item('b'), _item('c')]; + + final result = removeFromQueue(queue, 1); + + expect(result.map((e) => e.id), ['a', 'c']); + }); + + test('lässt die Original-Liste unverändert', () { + final queue = [_item('a'), _item('b')]; + + removeFromQueue(queue, 0); + + expect(queue.map((e) => e.id), ['a', 'b']); + }); + }); + + group('reorderQueue', () { + test('verschiebt einen Titel nach hinten', () { + final queue = [_item('a'), _item('b'), _item('c')]; + + final result = reorderQueue(queue, 0, 2); + + expect(result.map((e) => e.id), ['b', 'c', 'a']); + }); + + test('verschiebt einen Titel nach vorne', () { + final queue = [_item('a'), _item('b'), _item('c')]; + + final result = reorderQueue(queue, 2, 0); + + expect(result.map((e) => e.id), ['c', 'a', 'b']); + }); + }); + + group('newPlayingIndex', () { + test('findet den abgespielten Titel an seiner neuen Position', () { + final newQueue = [_item('b'), _item('c'), _item('a')]; + + final index = newPlayingIndex(newQueue, 'a', 0); + + expect(index, 2); + }); + + test('fällt auf fallbackIndex zurück, wenn der Titel entfernt wurde', () { + // 'b' wurde entfernt (war der abgespielte Titel); an Index 1 steht jetzt 'c'. + final newQueue = [_item('a'), _item('c')]; + + final index = newPlayingIndex(newQueue, 'b', 1); + + expect(index, 1); + }); + + test('begrenzt fallbackIndex auf die neue Listenlänge', () { + // Letzter Titel der Liste wurde entfernt, fallbackIndex zeigt hinter das Ende. + final newQueue = [_item('a')]; + + final index = newPlayingIndex(newQueue, 'b', 1); + + expect(index, 0); + }); + + test('gibt 0 zurück, wenn die neue Queue leer ist', () { + final index = newPlayingIndex(const [], 'a', 0); + + expect(index, 0); + }); + + test('nutzt fallbackIndex, wenn playingId null ist', () { + final newQueue = [_item('a'), _item('b')]; + + final index = newPlayingIndex(newQueue, null, 1); + + expect(index, 1); + }); + }); +}