diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5e14f..d46775d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,64 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### ▶️ Man sieht jetzt, welcher Titel gerade läuft (2026-08-23) + +Bisher scrollte man durch die Bibliothek und musste raten, wo man gerade +ist — die einzige Rückmeldung war die Leiste ganz unten. + +- 🎚️ **Drei tanzende Balken über dem Coverbild** des laufenden Titels, sein + Name in Rot. Die Balken liegen **auf** dem Cover statt daneben: eine vierte + Spalte hätte dem Titel Platz genommen, und die Zeile wäre bei jedem + Titelwechsel in der Breite gesprungen. +- ⏸️ **Pausiert bleibt die Markierung stehen, die Balken stehen still.** Man + sieht weiter, wo man ist, und zugleich, dass gerade nichts vorangeht. +- 🌐 **Überall zugleich.** Die Markierung sitzt in der Songzeile selbst, also + gilt sie in „Meine Musik“, Favoriten, Suche, hinter einem Künstler, hinter + einer Kategorie, in einer Wiedergabeliste und bei „Zuletzt hinzugefügt“ — + ohne dass irgendeine dieser Ansichten angefasst werden musste. Die + Warteschlange bekommt dasselbe Zeichen, damit man dieselbe Stelle + wiedererkennt. +- ♿ **Wer Systemanimationen abgeschaltet hat, bekommt stehende Balken.** Eine + Endlosschleife ist genau das, was diese Einstellung vermeiden soll. +- 🐛 **Beim Testen gefunden:** Die Balken lasen die Animations-Einstellung im + falschen Moment (`initState`), was Flutter mit einem Fehler quittiert. Der + Test, der prüft, dass nichts endlos läuft, hat es aufgedeckt. + +- ⚡ **Und beim Nachsehen noch etwas:** Zuerst hing *jede* sichtbare Zeile am + Wiedergabe-Zustand — der meldet aber auch den Pufferstand und feuert + mehrmals pro Sekunde. Die halbe Liste hätte sich also ständig neu + aufgebaut. Jetzt hört nur die eine laufende Zeile darauf; alle anderen + bekommen nur mit, wenn der Titel wechselt. +- 🔍 **Aus dem Code-Review nachgebessert:** + **(1)** Die Markierung erschien einen Frame zu spät. Scrollte man die + laufende Zeile aus dem Blick und zurück, ploppten Balken und rote Schrift + sichtbar nach — der Strom liefert seinen Wert erst im nächsten Frame. Jetzt + mit Startwert. + **(2)** Beim Wechsel auf „markiert" änderte die Zeile die Form ihres + Aufbaus, und Flutter warf sie deshalb weg und baute sie neu. Folge: das + Herz daneben verlor sein Abo und sah einen Moment lang leer aus — genau + bei dem Titel, den man gerade gestartet hat. Der Aufbau bleibt jetzt + gleich, nur der Strom wird an- und abgehängt. + **(3)** In der **Wiedergabeliste** fehlte die Markierung ganz. Der + Changelog-Eintrag oben behauptete zuerst das Gegenteil — diese Ansicht + baut ihre Zeilen selbst (rechts steht „Aus Playlist entfernen" statt Herz + und Menü) und lief deshalb nicht über die gemeinsame Songzeile. Sie hat + die Markierung jetzt wirklich. + Dazu eine Kleinigkeit: der Abdunkel-Schleier hatte seinen Eckenradius fest + verdrahtet und hätte bei einem anderen Cover-Radius nicht mehr gepasst. + +- 🧪 **Testlücke geschlossen, die der Review benannt hat.** Geprüft waren nur + die Regel und die Balken für sich — die Verdrahtung dazwischen nicht. Genau + dort saßen alle drei Fehler oben. Es gibt jetzt fünf Tests dafür, unter + anderem einen, der nachweist, dass die Zeile beim Wechsel **dasselbe** + Element bleibt. + +- ✅ **Getestet:** 411 Tests grün (vorher 396), `flutter analyze` ohne Befund, + Release-APK gebaut. Neu: 5 Tests für die Regel, wann eine Zeile markiert + wird (inklusive: ein Server-Titel markiert nie eine Bibliothekszeile), + 5 Widget-Tests für die Balken und 5 für die Verdrahtung. + + ### 🔎 Die Suche trennt jetzt nach Titel, Künstler, Kategorien und Listen (2026-08-23) Der Suchen-Tab war bisher **59 Zeilen**: ein Textfeld über einer flachen diff --git a/lib/library/song_list.dart b/lib/library/song_list.dart index ff8224e..943f9cc 100644 --- a/lib/library/song_list.dart +++ b/lib/library/song_list.dart @@ -7,10 +7,12 @@ import '../playlists/create_playlist_dialog.dart'; import '../settings/app_settings.dart'; import '../shared/cover.dart'; import '../shared/favorite_button.dart'; +import '../shared/lauf_balken.dart'; import 'category_service.dart'; import 'database.dart'; import 'playlist_service.dart'; import 'song_detail_sheet.dart'; +import '../shared/theme.dart'; import 'song_media.dart'; /// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab. @@ -185,20 +187,69 @@ class SongZeile extends StatelessWidget { @override Widget build(BuildContext context) { - // Der Handler wird erst beim Antippen gebraucht und deshalb auch erst - // dann gelesen: so lässt sich eine Zeile darstellen, ohne dass ein - // laufender Player bereitstehen muss. + // Nullable gelesen: ohne laufenden Player (etwa im Widget-Test) bleibt + // die Zeile darstellbar, nur ohne Lauf-Markierung. + final handler = context.read(); final categories = context.watch(); final settings = context.watch(); final cover = categories.coverFor(song, groupByCategory: settings.groupCoversByCategory); + return StreamBuilder( + stream: handler?.mediaItem, + // Der Strom liefert seinen aktuellen Wert erst im nächsten Frame. + // Ohne diesen Startwert erschiene die laufende Zeile beim Zurückscrollen + // einen Frame lang unmarkiert — Balken und rote Schrift ploppen nach. + initialData: handler?.mediaItem.valueOrNull, + builder: (context, laufSnap) { + final markiert = istAmLaufen(laufSnap.data, song.id); + // Nur die laufende Zeile hört auf den Wiedergabe-Zustand: der meldet + // auch den Pufferstand und feuert mehrmals pro Sekunde. Der + // StreamBuilder bleibt trotzdem immer stehen — verschwände er, + // änderte sich die Form des Baums, und das Herz daneben würde sein + // Abo verlieren und kurz leer aussehen. + return StreamBuilder( + stream: markiert ? handler?.playbackState : null, + initialData: handler?.playbackState.valueOrNull, + builder: (context, zustandSnap) => _zeile( + context, + categories: categories, + settings: settings, + cover: cover, + markiert: markiert, + spielt: markiert && (zustandSnap.data?.playing ?? false), + ), + ); + }, + ); + } + + Widget _zeile( + BuildContext context, { + required CategoryService categories, + required AppSettings settings, + required String? cover, + required bool markiert, + required bool spielt, + }) { return ListTile( - leading: CoverImage( - artUri: cover != null ? Uri.file(cover) : null, - size: 48, + leading: LaufMarkierung( + markiert: markiert, + spielt: spielt, radius: 6, + child: CoverImage( + artUri: cover != null ? Uri.file(cover) : null, + size: 48, + radius: 6, + ), + ), + title: Text( + song.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: markiert + ? const TextStyle(color: MeloTheme.red, fontWeight: FontWeight.w600) + : null, ), - title: Text(song.title, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Text( _subtitle(song, categories.of(song.id)), maxLines: 1, diff --git a/lib/player/audio_handler.dart b/lib/player/audio_handler.dart index 6d7a2d5..1ac4b91 100644 --- a/lib/player/audio_handler.dart +++ b/lib/player/audio_handler.dart @@ -88,6 +88,15 @@ String? navidromeIdOf(MediaItem item) => item.extras?['navidromeId'] as String?; /// der nur am Server liegt. Trägt Historie, Wiedergabezähler und Resume. String? songIdOf(MediaItem item) => item.extras?['songId'] as String?; +/// Ob der Titel mit der Bibliotheks-UUID [songId] gerade in der Wiedergabe +/// steht. [aktuell] ist der laufende Eintrag, oder `null`, wenn nichts läuft. +/// +/// Der leere [songId] gilt nie als laufend: Titel, die nur am Server liegen, +/// haben keine UUID, und ohne diese Prüfung markierte ein Server-Titel jede +/// Zeile, der ebenfalls eine fehlt. +bool istAmLaufen(MediaItem? aktuell, String songId) => + songId.isNotEmpty && aktuell != null && songIdOf(aktuell) == songId; + /// Ob für [item] im Server-Cache nachgesehen wird. Titel vom Gerät liegen /// bereits als Datei vor — für sie darf der Server nie befragt werden. bool nutztServerCache(MediaItem item) => navidromeIdOf(item) != null; diff --git a/lib/player/queue_screen.dart b/lib/player/queue_screen.dart index b15e71e..0751f5d 100644 --- a/lib/player/queue_screen.dart +++ b/lib/player/queue_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../shared/cover.dart'; +import '../shared/lauf_balken.dart'; import '../shared/theme.dart'; import 'audio_handler.dart'; @@ -38,31 +39,13 @@ class QueueScreen extends StatelessWidget { itemCount: queue.length, onReorderItem: (oldIndex, newIndex) => handler.moveQueueItem(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: () => handler.removeQueueItemAt(i), - ), - onTap: () => handler.skipToQueueItem(i), - ); - }, + itemBuilder: (context, i) => _Zeile( + key: ValueKey(queue[i].id), + item: queue[i], + laeuft: queue[i].id == currentId, + onSpringen: () => handler.skipToQueueItem(i), + onEntfernen: () => handler.removeQueueItemAt(i), + ), ); }, ); @@ -71,3 +54,71 @@ class QueueScreen extends StatelessWidget { ); } } + +/// Eine Zeile der Warteschlange. +/// +/// Eigene Klasse, damit der Wiedergabe-Zustand (läuft / pausiert) hier +/// beobachtet wird statt in einem dritten `StreamBuilder`, der die ganze +/// Liste umschließt und bei jedem Pausieren neu aufbaut. +class _Zeile extends StatelessWidget { + const _Zeile({ + super.key, + required this.item, + required this.laeuft, + required this.onSpringen, + required this.onEntfernen, + }); + + final MediaItem item; + + /// Dieser Eintrag steht in der Wiedergabe — auch pausiert. + final bool laeuft; + + final VoidCallback onSpringen; + final VoidCallback onEntfernen; + + @override + Widget build(BuildContext context) { + final handler = context.read(); + // Nur die laufende Zeile hört wirklich zu — der Zustand meldet auch den + // Pufferstand und feuert mehrmals pro Sekunde. Der StreamBuilder bleibt + // aber stehen, damit die Zeile beim Titelwechsel nicht ihre Form ändert + // und dabei ihr Coverbild neu laden muss. + return StreamBuilder( + stream: laeuft ? handler.playbackState : null, + initialData: handler.playbackState.valueOrNull, + builder: (context, zustandSnap) => + _kachel(spielt: laeuft && (zustandSnap.data?.playing ?? false)), + ); + } + + Widget _kachel({required bool spielt}) { + return ListTile( + // Dasselbe Zeichen wie in der Bibliothek: Balken über dem Cover, + // damit man dieselbe Stelle wiedererkennt. + leading: LaufMarkierung( + markiert: laeuft, + spielt: spielt, + radius: 6, + child: CoverImage(artUri: item.artUri, size: 48, radius: 6), + ), + title: Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: laeuft + ? 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: onEntfernen, + ), + onTap: onSpringen, + ); + } +} diff --git a/lib/playlists/playlist_detail_screen.dart b/lib/playlists/playlist_detail_screen.dart index 3fe0bd9..ba81b33 100644 --- a/lib/playlists/playlist_detail_screen.dart +++ b/lib/playlists/playlist_detail_screen.dart @@ -1,3 +1,4 @@ +import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,6 +9,7 @@ import '../library/song_media.dart'; import '../player/audio_handler.dart'; import '../settings/app_settings.dart'; import '../shared/cover.dart'; +import '../shared/lauf_balken.dart'; import '../shared/theme.dart'; /// Playlist-Detailansicht: Songs anzeigen, per Drag & Drop umsortieren, entfernen. @@ -18,10 +20,7 @@ class PlaylistDetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { final db = context.read(); - final handler = context.read(); final service = context.read(); - final categories = context.watch(); - final settings = context.watch(); return Scaffold( appBar: AppBar(title: Text(playlist.name)), body: StreamBuilder>( @@ -48,49 +47,106 @@ class PlaylistDetailScreen extends StatelessWidget { reordered.insert(newIndex, moved); service.reorderAll(playlist.id, reordered.map((s) => s.id).toList()); }, - itemBuilder: (context, i) { - final s = songs[i]; - return ListTile( - key: ValueKey(s.id), - leading: Builder(builder: (context) { - final cover = categories.coverFor(s, - groupByCategory: settings.groupCoversByCategory); - return CoverImage( - artUri: cover != null ? Uri.file(cover) : null, - size: 48, - radius: 6, - ); - }), - title: Text(s.title, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text(s.artist ?? 'Unbekannt', - maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: IconButton( - tooltip: 'Aus Playlist entfernen', - icon: const Icon(Icons.remove_circle_outline), - onPressed: () => service.removeSongFromPlaylist(playlist.id, s.id), - ), - onTap: () async { - try { - await playSongs( - handler, - songs, - i, - coverOf: (song) => categories.coverFor(song, - groupByCategory: settings.groupCoversByCategory), - ); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')), - ); - } - } - }, - ); - }, + itemBuilder: (context, i) => _Zeile( + key: ValueKey(songs[i].id), + song: songs[i], + alleSongs: songs, + index: i, + onEntfernen: () => + service.removeSongFromPlaylist(playlist.id, songs[i].id), + ), ); }, ), ); } } + +/// Eine Zeile der Wiedergabeliste. +/// +/// Nicht [SongZeile]: hier steht rechts „Aus Playlist entfernen" statt Herz +/// und Menü. Die Lauf-Markierung ist dieselbe — der Nutzer soll dasselbe +/// Zeichen sehen wie in der Bibliothek. +class _Zeile extends StatelessWidget { + const _Zeile({ + super.key, + required this.song, + required this.alleSongs, + required this.index, + required this.onEntfernen, + }); + + final Song song; + final List alleSongs; + final int index; + final VoidCallback onEntfernen; + + @override + Widget build(BuildContext context) { + final handler = context.read(); + final categories = context.watch(); + final settings = context.watch(); + final cover = categories.coverFor(song, + groupByCategory: settings.groupCoversByCategory); + return StreamBuilder( + stream: handler.mediaItem, + initialData: handler.mediaItem.valueOrNull, + builder: (context, laufSnap) { + final markiert = istAmLaufen(laufSnap.data, song.id); + return StreamBuilder( + stream: markiert ? handler.playbackState : null, + initialData: handler.playbackState.valueOrNull, + builder: (context, zustandSnap) => ListTile( + leading: LaufMarkierung( + markiert: markiert, + spielt: markiert && (zustandSnap.data?.playing ?? false), + radius: 6, + child: CoverImage( + artUri: cover != null ? Uri.file(cover) : null, + size: 48, + radius: 6, + ), + ), + title: Text( + song.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: markiert + ? const TextStyle( + color: MeloTheme.red, fontWeight: FontWeight.w600) + : null, + ), + subtitle: Text(song.artist ?? 'Unbekannt', + maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: IconButton( + tooltip: 'Aus Playlist entfernen', + icon: const Icon(Icons.remove_circle_outline), + onPressed: onEntfernen, + ), + onTap: () async { + final messenger = ScaffoldMessenger.of(context); + try { + final uebersprungen = await playSongs( + handler, + alleSongs, + index, + coverOf: (s) => categories.coverFor(s, + groupByCategory: settings.groupCoversByCategory), + ); + if (uebersprungen > 0) { + messenger.showSnackBar( + SnackBar(content: Text(offlineHinweis(uebersprungen))), + ); + } + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')), + ); + } + }, + ), + ); + }, + ); + } +} diff --git a/lib/shared/lauf_balken.dart b/lib/shared/lauf_balken.dart new file mode 100644 index 0000000..eb37319 --- /dev/null +++ b/lib/shared/lauf_balken.dart @@ -0,0 +1,175 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'theme.dart'; + +/// Drei tanzende Balken — das Zeichen dafür, dass genau dieser Titel läuft. +/// +/// Bei [laeuft] `false` (pausiert) stehen sie still, statt zu verschwinden: +/// die Zeile bleibt als „hier bist du gerade" markiert, zeigt aber, dass +/// nichts vorangeht. Sind die Systemanimationen abgeschaltet, stehen sie +/// ebenfalls still — eine Endlosschleife ist genau das, was diese +/// Einstellung vermeiden soll. +class LaufBalken extends StatefulWidget { + const LaufBalken({ + super.key, + required this.laeuft, + this.hoehe = 16, + this.farbe = Colors.white, + }); + + final bool laeuft; + final double hoehe; + final Color farbe; + + @override + State createState() => _LaufBalkenState(); +} + +class _LaufBalkenState extends State + with SingleTickerProviderStateMixin { + static const _anzahl = 3; + + /// Versatz je Balken, damit sie nicht im Gleichschritt springen. + static const _versatz = [0.0, 0.35, 0.7]; + + late final AnimationController _takt = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + + // Kein _richteTaktAus() in initState: die Entscheidung hängt an MediaQuery, + // und darauf darf man dort noch nicht zugreifen. didChangeDependencies + // läuft unmittelbar danach und erledigt es. + + @override + void didUpdateWidget(LaufBalken alt) { + super.didUpdateWidget(alt); + if (alt.laeuft != widget.laeuft) _richteTaktAus(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _richteTaktAus(); + } + + void _richteTaktAus() { + final ruhig = MediaQuery.disableAnimationsOf(context); + if (widget.laeuft && !ruhig) { + if (!_takt.isAnimating) _takt.repeat(); + } else { + _takt.stop(); + } + } + + @override + void dispose() { + _takt.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: widget.hoehe, + width: widget.hoehe, + child: AnimatedBuilder( + animation: _takt, + builder: (context, _) => Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + for (var i = 0; i < _anzahl; i++) + _Balken( + anteil: _anteil(i), + hoehe: widget.hoehe, + farbe: widget.farbe, + ), + ], + ), + ), + ); + } + + /// Höhe eines Balkens zwischen 0,3 und 1,0. Stillstehend eine feste, + /// ungleiche Form — damit man auch pausiert erkennt, was gemeint ist. + double _anteil(int i) { + if (!_takt.isAnimating) return const [0.4, 1.0, 0.6][i]; + final phase = (_takt.value + _versatz[i]) * 2 * math.pi; + return 0.3 + 0.7 * (0.5 + 0.5 * math.sin(phase)); + } +} + +class _Balken extends StatelessWidget { + const _Balken({ + required this.anteil, + required this.hoehe, + required this.farbe, + }); + + final double anteil; + final double hoehe; + final Color farbe; + + @override + Widget build(BuildContext context) { + return Container( + width: hoehe * 0.22, + height: hoehe * anteil, + decoration: BoxDecoration( + color: farbe, + borderRadius: BorderRadius.circular(hoehe * 0.11), + ), + ); + } +} + +/// Das Coverbild einer Songzeile mit der Lauf-Markierung darüber. +/// +/// Die Balken liegen **auf** dem Cover statt daneben: eine vierte Spalte in +/// der Zeile würde dem Titel Platz nehmen, und die Zeile spränge beim +/// Titelwechsel in der Breite. +class LaufMarkierung extends StatelessWidget { + const LaufMarkierung({ + super.key, + required this.markiert, + required this.spielt, + required this.child, + this.radius = MeloRadius.element, + }); + + /// Dieser Titel steht in der Wiedergabe — auch pausiert. + final bool markiert; + + /// Die Wiedergabe läuft tatsächlich (steuert nur die Bewegung). + final bool spielt; + + final Widget child; + + /// Muss zum Radius des Coverbilds darunter passen, sonst hat der Schleier + /// andere Ecken als das Bild. + final double radius; + + @override + Widget build(BuildContext context) { + if (!markiert) return child; + return Stack( + alignment: Alignment.center, + children: [ + child, + // Abdunkeln, damit die weißen Balken auch auf hellen Covern stehen. + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(radius), + ), + ), + ), + LaufBalken(laeuft: spielt), + ], + ); + } +} diff --git a/test/library/song_zeile_lauf_test.dart b/test/library/song_zeile_lauf_test.dart new file mode 100644 index 0000000..3f9057e --- /dev/null +++ b/test/library/song_zeile_lauf_test.dart @@ -0,0 +1,178 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:drift/drift.dart' show Value, driftRuntimeOptions; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:melo/library/category_service.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/library/song_list.dart'; +import 'package:melo/library/song_media.dart'; +import 'package:melo/player/audio_handler.dart'; +import 'package:melo/settings/app_settings.dart'; +import 'package:melo/shared/lauf_balken.dart'; + +/// Die Verdrahtung zwischen Player und Songzeile — der Teil zwischen der +/// reinen Regel [istAmLaufen] und dem isolierten [LaufMarkierung]. Genau +/// hier saßen die Fehler: fehlender Startwert (die Markierung erschien einen +/// Frame zu spät) und ein Formwechsel des Widget-Baums, der das Herz daneben +/// sein Abo verlieren ließ. +void main() { + late MeloDb db; + late CategoryService kategorien; + late PlaylistService playlists; + late AppSettings einstellungen; + late MeloAudioHandler handler; + + /// Die Plugin-Kanäle, die `MeloAudioHandler` beim Bauen anfasst. Ohne diese + /// Antworten werfen sie asynchron und der Testlauf meldet Fremdfehler. + void stelleKanaeleRuhig() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => '/tmp/melo-test', + ); + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => null, + ); + } + + setUp(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + SharedPreferences.setMockInitialValues({}); + stelleKanaeleRuhig(); + db = MeloDb(NativeDatabase.memory()); + kategorien = CategoryService(db); + playlists = PlaylistService(db); + einstellungen = AppSettings(); + await einstellungen.init(); + handler = MeloAudioHandler(db: db); + await db.into(db.songs).insert(SongsCompanion.insert( + id: 'song-1', + path: '/music/roses.mp3', + title: 'Roses', + artist: const Value('Neoni'), + dateAddedMs: 0, + updatedAtMs: 0, + )); + }); + + tearDown(() { + handler.dispose(); + kategorien.dispose(); + playlists.dispose(); + }); + + Song derSong() => Song( + id: 'song-1', + path: '/music/roses.mp3', + title: 'Roses', + artist: 'Neoni', + dateAddedMs: 0, + updatedAtMs: 0, + deleted: false, + playCount: 0, + categoriesEdited: false, + metadataEdited: false, + ); + + Future pumpe(WidgetTester tester) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: db), + Provider.value(value: handler), + ChangeNotifierProvider.value(value: kategorien), + ChangeNotifierProvider.value(value: playlists), + ChangeNotifierProvider.value(value: einstellungen), + ], + child: MaterialApp( + home: Scaffold( + body: SongZeile( + song: derSong(), + warteschlange: [derSong()], + index: 0, + ), + ), + ), + ), + ); + } + + Future aufraeumen(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(milliseconds: 1)); + } + + testWidgets('ohne Wiedergabe bleibt die Zeile unmarkiert', (tester) async { + await pumpe(tester); + await tester.pump(); + + expect(find.byType(LaufBalken), findsNothing); + + await aufraeumen(tester); + }); + + testWidgets('läuft der Titel, ist die Zeile markiert', (tester) async { + handler.mediaItem.add(songToMediaItem(derSong())); + await pumpe(tester); + await tester.pump(); + + expect(find.byType(LaufBalken), findsOneWidget); + + await aufraeumen(tester); + }); + + testWidgets('die Markierung steht schon im ersten Frame', (tester) async { + // Ohne initialData liefert der BehaviorSubject erst im nächsten Frame — + // die Balken ploppten beim Zurückscrollen sichtbar nach. + handler.mediaItem.add(songToMediaItem(derSong())); + await tester.pump(); + + await pumpe(tester); + // Genau ein Frame, kein zweiter. + expect(find.byType(LaufBalken), findsOneWidget); + + await aufraeumen(tester); + }); + + testWidgets('ein anderer Titel markiert diese Zeile nicht', (tester) async { + handler.mediaItem.add(const MediaItem( + id: 'file:///music/anderes.mp3', + title: 'Anderes', + extras: {'songId': 'song-2'}, + )); + await pumpe(tester); + await tester.pump(); + + expect(find.byType(LaufBalken), findsNothing); + + await aufraeumen(tester); + }); + + testWidgets('der Wechsel zu markiert wirft die Zeile nicht weg', + (tester) async { + // Ein Formwechsel des Widget-Baums hätte das Herz daneben sein Abo + // verlieren lassen; es sähe für einen Frame leer aus. + await pumpe(tester); + await tester.pump(); + final vorher = tester.element(find.byType(ListTile)); + + handler.mediaItem.add(songToMediaItem(derSong())); + // Zwei Frames: der Strom liefert erst nach einem Microtask-Durchlauf. + await tester.pump(); + await tester.pump(); + + expect(find.byType(LaufBalken), findsOneWidget); + // Dasselbe Element wie vorher — der Baum wurde nicht verworfen. + expect(tester.element(find.byType(ListTile)), same(vorher)); + + await aufraeumen(tester); + }); +} diff --git a/test/player/laeuft_gerade_test.dart b/test/player/laeuft_gerade_test.dart new file mode 100644 index 0000000..59729c2 --- /dev/null +++ b/test/player/laeuft_gerade_test.dart @@ -0,0 +1,58 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/song_media.dart'; +import 'package:melo/player/audio_handler.dart'; + +Song _song(String id) => Song( + id: id, + path: '/music/$id.mp3', + title: 'Titel $id', + dateAddedMs: 0, + updatedAtMs: 0, + deleted: false, + playCount: 0, + categoriesEdited: false, + metadataEdited: false, + ); + +/// Ohne diese Regel zeigten die Listen nicht, wo man gerade ist: man scrollte +/// durch die Bibliothek und musste raten, welcher Titel läuft. +void main() { + group('istAmLaufen', () { + test('erkennt den laufenden Titel an seiner UUID', () { + final laufend = songToMediaItem(_song('uuid-1')); + + expect(istAmLaufen(laufend, 'uuid-1'), isTrue); + }); + + test('ein anderer Titel läuft nicht', () { + final laufend = songToMediaItem(_song('uuid-1')); + + expect(istAmLaufen(laufend, 'uuid-2'), isFalse); + }); + + test('ohne Wiedergabe läuft nichts', () { + expect(istAmLaufen(null, 'uuid-1'), isFalse); + }); + + test('ein Server-Titel markiert keine Zeile der Bibliothek', () { + // Er trägt nur eine navidromeId, keine UUID — ohne die Prüfung auf den + // leeren String würde er jede Zeile ohne UUID markieren. + const serverTitel = MediaItem( + id: 'https://musik.example/rest/stream.view?id=abc', + title: 'Serverlied', + extras: {'navidromeId': 'abc'}, + ); + + expect(istAmLaufen(serverTitel, ''), isFalse); + expect(istAmLaufen(serverTitel, 'uuid-1'), isFalse); + }); + + test('eine Zeile ohne UUID wird nie markiert', () { + final laufend = songToMediaItem(_song('uuid-1')); + + expect(istAmLaufen(laufend, ''), isFalse); + }); + }); +} diff --git a/test/shared/lauf_balken_test.dart b/test/shared/lauf_balken_test.dart new file mode 100644 index 0000000..5e85ea8 --- /dev/null +++ b/test/shared/lauf_balken_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/shared/lauf_balken.dart'; + +void main() { + Future pumpe( + WidgetTester tester, { + required bool markiert, + required bool spielt, + bool ruhig = false, + }) { + return tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: ruhig), + child: Scaffold( + body: Center( + child: LaufMarkierung( + markiert: markiert, + spielt: spielt, + child: const SizedBox( + width: 48, + height: 48, + child: Text('Cover'), + ), + ), + ), + ), + ), + ), + ); + } + + testWidgets('ohne Markierung steht nur das Cover da', (tester) async { + await pumpe(tester, markiert: false, spielt: false); + await tester.pumpAndSettle(); + + expect(find.text('Cover'), findsOneWidget); + expect(find.byType(LaufBalken), findsNothing); + }); + + testWidgets('der laufende Titel bekommt die Balken über das Cover', + (tester) async { + await pumpe(tester, markiert: true, spielt: true); + await tester.pump(); + + expect(find.text('Cover'), findsOneWidget); + expect(find.byType(LaufBalken), findsOneWidget); + }); + + testWidgets('pausiert bleibt die Markierung, die Balken stehen still', + (tester) async { + await pumpe(tester, markiert: true, spielt: false); + // Steht etwas still, kommt pumpAndSettle zurück. Liefe die Animation + // weiter, liefe dieser Aufruf in die Zeitüberschreitung. + await tester.pumpAndSettle(); + + expect(find.byType(LaufBalken), findsOneWidget); + }); + + testWidgets('bei abgeschalteten Systemanimationen läuft nichts endlos', + (tester) async { + await pumpe(tester, markiert: true, spielt: true, ruhig: true); + // Dasselbe Kriterium: eine Endlosschleife brächte pumpAndSettle nie + // zurück. Genau davor schützt die Einstellung. + await tester.pumpAndSettle(); + + expect(find.byType(LaufBalken), findsOneWidget); + }); + + testWidgets('die Balken verschwinden, wenn der Titel wechselt', + (tester) async { + await pumpe(tester, markiert: true, spielt: false); + await tester.pumpAndSettle(); + expect(find.byType(LaufBalken), findsOneWidget); + + await pumpe(tester, markiert: false, spielt: false); + await tester.pumpAndSettle(); + expect(find.byType(LaufBalken), findsNothing); + }); +}