From 5f7b652eb67bcaa7035b0c5672672472ce5266aa Mon Sep 17 00:00:00 2001 From: Dustin Date: Wed, 5 Aug 2026 12:20:32 +0200 Subject: [PATCH] =?UTF-8?q?v2.54.1=20=E2=80=94=20Queue=20zeigt=20echte=20W?= =?UTF-8?q?iedergabe-Reihenfolge=20(inkl.=20Shuffle),=20aktuelles=20Lied?= =?UTF-8?q?=20markiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/screens/now_playing_screen.dart | 15 ++- lib/services/player_service.dart | 62 ++++++++++++ lib/widgets/warteschlange_sheet.dart | 141 ++++++++++++++++++++++----- test/player_service_test.dart | 96 ++++++++++++++++++ test/widget_test.dart | 52 ++++++++++ 5 files changed, 333 insertions(+), 33 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index b2583aa..6965e55 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -288,15 +288,12 @@ class _NowPlayingScreenState extends State ); } - /// Menü „Als Nächstes": zeigt die kommenden Songs der Warteschlange. - /// Tipp = Song wird als Nächstes gespielt (spieleAlsNaechstes). + /// Menü „Als Nächstes": zeigt die kommenden Songs in der tatsächlichen + /// Wiedergabe-Reihenfolge (inkl. Shuffle). Tipp = Song wird als Nächstes + /// gespielt (spieleAlsNaechstes). void _zeigePlayNextMenue() { - final queue = _player.warteschlange; - final aktuell = _player.aktuellerIndex; - final kommende = []; - for (var i = aktuell + 1; i < queue.length; i++) { - kommende.add(queue[i]); - } + // Position 0 = aktuell spielender Song → nur die kommenden anzeigen + final kommende = _player.wiedergabeReihenfolge.skip(1).toList(); showModalBottomSheet( context: context, isScrollControlled: true, @@ -356,7 +353,7 @@ class _NowPlayingScreenState extends State return ListTile( dense: true, leading: Text( - '${aktuell + i + 2}', + '${i + 1}', // Position in der Wiedergabe-Reihenfolge style: const TextStyle( fontSize: 11, color: MeloTheme.textSekundaer), ), diff --git a/lib/services/player_service.dart b/lib/services/player_service.dart index 3e58a56..e4e923d 100644 --- a/lib/services/player_service.dart +++ b/lib/services/player_service.dart @@ -341,6 +341,68 @@ class PlayerService extends ChangeNotifier { /// Index des aktuell spielenden Songs in der Warteschlange. int get aktuellerIndex => _aktuellerIndex; + /// Die tatsächliche Wiedergabe-Reihenfolge — beginnend mit dem aktuell + /// spielenden Song (Position 0). NICHT die Bibliotheks-Reihenfolge: + /// - Normal: Warteschlange ab dem aktuellen Index + /// - Shuffle: aktuelle Shuffle-Position + Rest der Zufallsreihenfolge + List get wiedergabeReihenfolge { + final ergebnis = []; + if (_warteschlange.isEmpty) return List.unmodifiable(ergebnis); + if (_aktuellerIndex >= 0 && _aktuellerIndex < _warteschlange.length) { + ergebnis.add(_warteschlange[_aktuellerIndex]); + } + if (_zufallsmodus) { + if (_shuffleReihenfolge.isNotEmpty) { + // _shufflePos == -1 → Shuffle gerade eingeschaltet, aktueller Song + // ist noch nicht in der Reihenfolge → ab Position 0 auflisten + final start = _shufflePos >= 0 ? _shufflePos + 1 : 0; + for (var i = start; i < _shuffleReihenfolge.length; i++) { + final idx = _shuffleReihenfolge[i]; + if (idx >= 0 && idx < _warteschlange.length) { + ergebnis.add(_warteschlange[idx]); + } + } + } + } else { + for (var i = _aktuellerIndex + 1; i < _warteschlange.length; i++) { + ergebnis.add(_warteschlange[i]); + } + } + return List.unmodifiable(ergebnis); + } + + /// Verschiebt einen Eintrag in der tatsächlichen Wiedergabe-Reihenfolge + /// (Drag-and-Drop im Queue-Sheet). Position 0 = aktuell spielender Song + /// (bleibt oben und kann nicht verschoben werden). + /// - Normal: verschiebt in der Warteschlange (ab dem aktuellen Index) + /// - Shuffle: verschiebt in der Zufallsreihenfolge — das ändert die echte + /// Abspiel-Reihenfolge, ohne die Bibliotheks-Liste anzufassen + void verschiebeInWiedergabeReihenfolge(int von, int nach) { + if (von <= 0) return; // aktueller Song bleibt an Position 0 + if (nach <= 0) nach = 1; // nie vor den aktuellen Song + if (von == nach) return; + if (_warteschlange.isEmpty) return; + + if (_zufallsmodus) { + final start = _shufflePos >= 0 ? _shufflePos + 1 : 0; + final vonShuffle = start + von - 1; + final nachShuffle = start + nach - 1; + if (vonShuffle < 0 || vonShuffle >= _shuffleReihenfolge.length) return; + final item = _shuffleReihenfolge.removeAt(vonShuffle); + final ziel = nachShuffle > _shuffleReihenfolge.length + ? _shuffleReihenfolge.length + : nachShuffle; + _shuffleReihenfolge.insert(ziel, item); + notifyListeners(); + return; + } + + final vonQueue = _aktuellerIndex + von; + final nachQueue = _aktuellerIndex + nach; + if (vonQueue < 0 || vonQueue >= _warteschlange.length) return; + verschiebeInWarteschlange(vonQueue, nachQueue); + } + /// Setzt [song] als nächstes auf die Wiedergabe — direkt nach dem aktuellen /// Song. Ist der Song bereits in der Warteschlange, wird er dorthin VERSCHOBEN /// statt dupliziert. Im Shuffle wird die Reihenfolge nicht neu gemischt: diff --git a/lib/widgets/warteschlange_sheet.dart b/lib/widgets/warteschlange_sheet.dart index cf42796..b4f4521 100644 --- a/lib/widgets/warteschlange_sheet.dart +++ b/lib/widgets/warteschlange_sheet.dart @@ -37,8 +37,10 @@ class _WarteschlangeSheetState extends State { return ListenableBuilder( listenable: _player, builder: (context, _) { - final queue = _player.warteschlange; - final aktueller = _player.aktuellerIndex; + // Echte Wiedergabe-Reihenfolge (Position 0 = aktuell spielender Song): + // normal = Warteschlange ab aktuellem Index, Shuffle = Zufallsreihenfolge. + final reihenfolge = _player.wiedergabeReihenfolge; + final wiederholung = _player.wiederholmodus == Wiederholmodus.playlist; return SafeArea( child: Padding( padding: EdgeInsets.only( @@ -78,7 +80,7 @@ class _WarteschlangeSheetState extends State { ), const SizedBox(width: 8), Text( - '${queue.length} Titel', + '${reihenfolge.length} Titel', style: const TextStyle( fontSize: 12, color: MeloTheme.textSekundaer, @@ -96,8 +98,9 @@ class _WarteschlangeSheetState extends State { const Divider(height: 1, color: MeloTheme.dunkel2), _sleepTimerZeile(), const Divider(height: 1, color: MeloTheme.dunkel2), - // Liste - if (queue.isEmpty) + // Liste: aktueller Song oben (markiert, nicht verschiebbar), + // danach die tatsächlichen nächsten Lieder (Drag = umsortieren) + if (reihenfolge.isEmpty) const Expanded( child: Center( child: Text( @@ -106,18 +109,38 @@ class _WarteschlangeSheetState extends State { ), ), ) - else + else ...[ Flexible( child: ReorderableListView.builder( shrinkWrap: true, - padding: const EdgeInsets.fromLTRB(8, 4, 8, 16), - itemCount: queue.length, + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + itemCount: reihenfolge.length, + // Eigene Drag-Handles: nur kommende Songs sind + // verschiebbar, der aktuelle bleibt oben gepinnt. + buildDefaultDragHandles: false, // onReorderItem liefert den newIndex bereits angepasst onReorderItem: (alt, neu) => - _player.verschiebeInWarteschlange(alt, neu), - itemBuilder: (_, i) => _eintrag(queue[i], i, aktueller), + _player.verschiebeInWiedergabeReihenfolge(alt, neu), + itemBuilder: (_, i) { + // Der Key MUSS am obersten Widget des Items liegen + // (Pflicht für ReorderableListView) + final key = ValueKey( + 'pos$i-${reihenfolge[i].id ?? reihenfolge[i].titel}'); + final eintrag = _eintrag(reihenfolge[i], i); + if (i == 0) { + return KeyedSubtree(key: key, child: eintrag); + } + return ReorderableDelayedDragStartListener( + key: key, + index: i, + child: eintrag, + ); + }, ), ), + if (wiederholung) + _wiederholungHinweis(), + ], ], ), ), @@ -127,6 +150,32 @@ class _WarteschlangeSheetState extends State { ); } + /// Hinweis am Listenende, wenn Repeat (Playlist) aktiv ist: Nach dem + /// letzten Eintrag beginnt die Wiedergabe wieder von vorn (bzw. wird + /// im Shuffle neu gemischt). + Widget _wiederholungHinweis() { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 12), + child: Row( + children: [ + const Icon(Icons.repeat, size: 14, color: MeloTheme.rot), + const SizedBox(width: 6), + Expanded( + child: Text( + _player.zufallsmodus + ? 'Wiederholung aktiv — danach wird neu gemischt' + : 'Wiederholung aktiv — danach geht es von vorn weiter', + style: const TextStyle( + fontSize: 11, + color: MeloTheme.textSekundaer, + ), + ), + ), + ], + ), + ); + } + /// Sleep-Timer-Zeile: Optionen Aus/15/30/45/60 Min + Restzeit wenn aktiv Widget _sleepTimerZeile() { final aktiv = _player.sleepTimerAktiv; @@ -196,29 +245,73 @@ class _WarteschlangeSheetState extends State { return '⏱ ${min.toString().padLeft(2, '0')}:${sek.toString().padLeft(2, '0')}'; } - Widget _eintrag(Song song, int index, int aktueller) { - final istAktuell = index == aktueller; + /// Ein Eintrag der Wiedergabe-Reihenfolge. Position 0 = aktuell spielender + /// Song: deutlich markiert (▶ + Highlight-Hintergrund + „Jetzt spielt"-Chip). + Widget _eintrag(Song song, int position) { + final istAktuell = position == 0; return ListTile( - key: ValueKey('$index-${song.id ?? song.titel}'), dense: true, + // tileColor direkt am ListTile statt Container-Decoration: verhindert + // die „ListTile background color may be invisible"-Assertion + tileColor: istAktuell ? MeloTheme.rotHell : null, + shape: istAktuell + ? RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)) + : null, leading: istAktuell - ? const Icon(Icons.play_arrow, color: MeloTheme.rot, size: 18) + ? const Icon(Icons.play_arrow, color: MeloTheme.rot, size: 20) : SizedBox( width: 18, child: Text( - '${index + 1}', + '$position', // Position in der Wiedergabe-Reihenfolge textAlign: TextAlign.center, - style: const TextStyle(fontSize: 11, color: MeloTheme.textSekundaer), + style: const TextStyle( + fontSize: 11, color: MeloTheme.textSekundaer), + ), + ), + title: istAktuell + ? Row( + children: [ + Expanded( + child: Text( + song.titel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: MeloTheme.rot, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: MeloTheme.rot, + borderRadius: BorderRadius.circular(10), + ), + child: const Text( + 'Jetzt spielt', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ) + : Text( + song.titel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.white, ), ), - title: Text( - song.titel, - style: TextStyle( - fontSize: 13, - fontWeight: istAktuell ? FontWeight.w700 : FontWeight.w400, - color: istAktuell ? MeloTheme.rot : Colors.white, - ), - ), subtitle: Text( song.kuenstler, style: const TextStyle(fontSize: 11, color: MeloTheme.textSekundaer), diff --git a/test/player_service_test.dart b/test/player_service_test.dart index e8c38c5..b73fb68 100644 --- a/test/player_service_test.dart +++ b/test/player_service_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:melo_app/config/app_config.dart'; import 'package:melo_app/models/song.dart'; import 'package:melo_app/services/player_service.dart'; @@ -18,6 +19,9 @@ void main() { p.setWiederholmodus(Wiederholmodus.aus); p.cancelSleepTimer(); p.setWarteschlange([]); + // spiele() mit ungültiger Quelle loggt einen Fehler — ohne Netzwerk-Send + // (sonst versucht der Test einen echten HTTP-Post an den Log-Server). + AppConfig.sendeDiagnosedaten = false; }); group('Warteschlange', () { @@ -200,6 +204,98 @@ void main() { }); }); + group('Wiedergabe-Reihenfolge (Queue zeigt echte Reihenfolge)', () { + test('normal: aktueller Song zuerst, dann der Rest der Warteschlange', () { + final p = PlayerService(); + p.setWarteschlange( + [_song(1, 'A'), _song(2, 'B'), _song(3, 'C'), _song(4, 'D')], + startIndex: 1); + // NICHT die Bibliotheks-Liste [1,2,3,4] — sondern ab dem aktuellen Song + expect(p.wiedergabeReihenfolge.map((s) => s.id).toList(), [2, 3, 4]); + }); + + test('shuffle: folgt der Zufallsreihenfolge ab der aktuellen Position', () { + final p = PlayerService(); + p.setWarteschlange([for (var i = 1; i <= 5; i++) _song(i, 'S$i')]); + p.setZufallsmodus(true); + final vorher = p.wiedergabeReihenfolge; + expect(vorher.length, 5); + expect(vorher.first.id, 1); // aktueller Song zuerst + expect(vorher.map((s) => s.id).toSet(), {1, 2, 3, 4, 5}); + // Der erste kommende Eintrag ist exakt der, den naechsterIndex() spielt + // (naechsterIndex liefert Queue-Indizes, daher über die Warteschlange vergleichen) + final naechster = p.naechsterIndex(); + expect(p.warteschlange[naechster!].id, vorher[1].id); + }); + + test('shuffle nach Songwechsel: beginnt mit dem neuen aktuellen Song', () { + final p = PlayerService(); + p.setWarteschlange([for (var i = 1; i <= 5; i++) _song(i, 'S$i')]); + p.setZufallsmodus(true); + final naechster = p.naechsterIndex()!; + p.spiele(p.warteschlange[naechster]); // Shuffle-Position wird synchronisiert + final reihenfolge = p.wiedergabeReihenfolge; + expect(reihenfolge.first.id, p.warteschlange[naechster].id); + // Der beim Mischen ausgeschlossene Song (Index 0 / id 1) taucht erst + // wieder auf, wenn er manuell gewählt wird — der Rest der Rotation + // (4 Songs) ist vollständig enthalten + expect(reihenfolge.length, 4); + expect(reihenfolge.map((s) => s.id).toSet(), {2, 3, 4, 5}); + }); + + test('shuffle mit Einzel-Song-Queue: nur der aktuelle Song', () { + final p = PlayerService(); + p.setWarteschlange([_song(1, 'A')]); + p.setZufallsmodus(true); + expect(p.wiedergabeReihenfolge.map((s) => s.id).toList(), [1]); + }); + + test('verschieben (normal): ändert Wiedergabe-Reihenfolge und Queue', () { + final p = PlayerService(); + p.setWarteschlange( + [_song(1, 'A'), _song(2, 'B'), _song(3, 'C'), _song(4, 'D')], + startIndex: 1); + p.verschiebeInWiedergabeReihenfolge(2, 1); // D vor C ziehen + expect(p.wiedergabeReihenfolge.map((s) => s.id).toList(), [2, 4, 3]); + expect(p.warteschlange.map((s) => s.id).toList(), [1, 2, 4, 3]); + expect(p.aktuellerIndex, 1); // aktueller Song bleibt an Position 0 + }); + + test('verschieben (shuffle): ändert Zufallsreihenfolge, nicht die Queue', () { + final p = PlayerService(); + p.setWarteschlange([for (var i = 1; i <= 5; i++) _song(i, 'S$i')]); + p.setZufallsmodus(true); + final vorher = p.wiedergabeReihenfolge; + p.verschiebeInWiedergabeReihenfolge(4, 1); // letzten vor den ersten Kommenden + final nachher = p.wiedergabeReihenfolge; + expect(nachher.length, vorher.length); + expect(nachher.map((s) => s.id).toSet(), vorher.map((s) => s.id).toSet()); + expect(nachher[1].id, vorher[4].id); + expect(nachher[2].id, vorher[1].id); + // Bibliotheks-Queue bleibt unangetastet (Shuffle-Liste ist die Wiedergabe) + expect(p.warteschlange.map((s) => s.id).toList(), [1, 2, 3, 4, 5]); + // Der verschobene Song wird wirklich als Nächstes gespielt + final naechster = p.naechsterIndex(); + expect(p.warteschlange[naechster!].id, nachher[1].id); + }); + + test('Position 0 (aktueller Song) kann nicht verschoben werden', () { + final p = PlayerService(); + p.setWarteschlange([_song(1, 'A'), _song(2, 'B'), _song(3, 'C')]); + p.verschiebeInWiedergabeReihenfolge(0, 2); // von = aktueller Song → No-op + expect(p.warteschlange.map((s) => s.id).toList(), [1, 2, 3]); + // „Vor" den aktuellen Song ziehen landet auf Position 1 (danach) + p.verschiebeInWiedergabeReihenfolge(2, 0); + expect(p.warteschlange.map((s) => s.id).toList(), [1, 3, 2]); + }); + + test('leere Warteschlange: No-op', () { + final p = PlayerService(); + p.verschiebeInWiedergabeReihenfolge(1, 2); + expect(p.wiedergabeReihenfolge, isEmpty); + }); + }); + group('Sleep-Timer', () { test('setSleepTimer aktiviert + Restzeit vorhanden', () { final p = PlayerService(); diff --git a/test/widget_test.dart b/test/widget_test.dart index 992d9eb..b3535d1 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,12 +1,64 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; +import 'package:melo_app/models/song.dart'; import 'package:melo_app/screens/login_screen.dart'; +import 'package:melo_app/services/player_service.dart'; +import 'package:melo_app/widgets/warteschlange_sheet.dart'; + +Song _song(int id, String titel) => Song( + id: id, + titel: titel, + kuenstler: 'Künstler $id', + dauerSekunden: 100, + dateiPfad: '', +); void main() { + setUp(() { + // PlayerService ist ein Singleton — Zustand vor jedem Test zurücksetzen + final p = PlayerService(); + p.setZufallsmodus(false); + p.setWiederholmodus(Wiederholmodus.aus); + p.cancelSleepTimer(); + p.setWarteschlange([]); + }); + testWidgets('LoginScreen zeigt Anmelde-Felder', (WidgetTester tester) async { await tester.pumpWidget(const MaterialApp(home: LoginScreen())); await tester.pumpAndSettle(); // Prüft dass was gerendert wird (kein Crash) expect(find.byType(TextField), findsWidgets); }); + + testWidgets('WarteschlangeSheet zeigt echte Wiedergabe-Reihenfolge', + (WidgetTester tester) async { + final p = PlayerService(); + p.setWarteschlange( + [_song(1, 'Aktuell'), _song(2, 'Naechster'), _song(3, 'Danach')], + startIndex: 1); + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: WarteschlangeSheet()))); + await tester.pump(); + + // Aktueller Song oben, deutlich markiert (▶ + „Jetzt spielt"-Chip) + expect(find.text('Jetzt spielt'), findsOneWidget); + expect(find.byIcon(Icons.play_arrow), findsOneWidget); + // Echte Reihenfolge: aktueller Song zuerst, dann die nächsten Lieder + expect(find.text('Naechster'), findsOneWidget); + expect(find.text('Danach'), findsOneWidget); + // Kopfzeile zählt die Wiedergabe-Reihenfolge (nicht die Bibliothek) + expect(find.text('2 Titel'), findsOneWidget); + }); + + testWidgets('WarteschlangeSheet zeigt Wiederholung-Hinweis bei Repeat', + (WidgetTester tester) async { + final p = PlayerService(); + p.setWarteschlange([_song(1, 'A'), _song(2, 'B'), _song(3, 'C')]); + p.setWiederholmodus(Wiederholmodus.playlist); + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: WarteschlangeSheet()))); + await tester.pump(); + + expect(find.textContaining('Wiederholung aktiv'), findsOneWidget); + }); }