Listen zeigen, welcher Titel gerade laeuft
Bisher scrollte man durch die Bibliothek und musste raten, wo man ist — die einzige Rueckmeldung war die Leiste ganz unten. Drei tanzende Balken ueber dem Coverbild des laufenden Titels, sein Name in Rot. Die Balken liegen auf dem Cover statt daneben: eine vierte Spalte haette dem Titel Platz genommen und die Zeile bei jedem Titelwechsel in der Breite springen lassen. Pausiert bleibt die Markierung stehen, die Balken stehen still. Bei abgeschalteten Systemanimationen ebenfalls — eine Endlosschleife ist genau das, was diese Einstellung vermeiden soll. Die Markierung sitzt in SongZeile und gilt damit in einem Rutsch fuer Meine Musik, Favoriten, Suche, Kuenstler, Kategorie und "Zuletzt hinzugefuegt". Warteschlange und Wiedergabeliste bauen ihre Zeilen selbst (dort steht rechts Entfernen statt Herz und Menue) und haben dasselbe Zeichen einzeln bekommen. Nur die laufende Zeile hoert auf den Wiedergabe-Zustand: der meldet auch den Pufferstand und feuert mehrmals pro Sekunde. Haenge jede sichtbare Zeile daran, baute sich die halbe Liste staendig neu auf. Aus dem Code-Review nachgebessert: Startwert fuer den Strom, sonst erschien die Markierung beim Zurueckscrollen einen Frame zu spaet; die Zeile aendert beim Wechsel nicht mehr die Form ihres Aufbaus, sonst verlor das Herz daneben sein Abo und sah kurz leer aus; die Wiedergabeliste bekam die Markierung ueberhaupt erst (der Changelog hatte sie faelschlich schon behauptet); der Abdunkel-Schleier nimmt den Eckenradius jetzt entgegen statt ihn fest zu verdrahten. Testluecke geschlossen, die der Review benannt hat: geprueft waren nur die Regel und die Balken je fuer sich, die Verdrahtung dazwischen nicht — genau dort sassen alle drei Fehler. 411 Tests gruen (vorher 396), flutter analyze ohne Befund, Release-APK gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
This commit is contained in:
co-authored by
Claude Opus 5
parent
ca28264c0e
commit
80948f1ad5
@@ -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
|
||||
|
||||
@@ -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<MeloAudioHandler?>();
|
||||
final categories = context.watch<CategoryService>();
|
||||
final settings = context.watch<AppSettings>();
|
||||
final cover = categories.coverFor(song,
|
||||
groupByCategory: settings.groupCoversByCategory);
|
||||
return StreamBuilder<MediaItem?>(
|
||||
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<PlaybackState>(
|
||||
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(
|
||||
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),
|
||||
),
|
||||
title: Text(
|
||||
song.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: markiert
|
||||
? const TextStyle(color: MeloTheme.red, fontWeight: FontWeight.w600)
|
||||
: null,
|
||||
),
|
||||
subtitle: Text(
|
||||
_subtitle(song, categories.of(song.id)),
|
||||
maxLines: 1,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,17 +39,74 @@ 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;
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<MeloAudioHandler>();
|
||||
// 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<PlaybackState>(
|
||||
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(
|
||||
key: ValueKey(item.id),
|
||||
leading: CoverImage(artUri: item.artUri, size: 48, radius: 6),
|
||||
// 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: playing
|
||||
style: laeuft
|
||||
? const TextStyle(
|
||||
color: MeloTheme.red, fontWeight: FontWeight.bold)
|
||||
: null,
|
||||
@@ -58,16 +116,9 @@ class QueueScreen extends StatelessWidget {
|
||||
trailing: IconButton(
|
||||
tooltip: 'Aus Warteschlange entfernen',
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => handler.removeQueueItemAt(i),
|
||||
),
|
||||
onTap: () => handler.skipToQueueItem(i),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
onPressed: onEntfernen,
|
||||
),
|
||||
onTap: onSpringen,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MeloDb>();
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
final service = context.read<PlaylistService>();
|
||||
final categories = context.watch<CategoryService>();
|
||||
final settings = context.watch<AppSettings>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(playlist.name)),
|
||||
body: StreamBuilder<List<Song>>(
|
||||
@@ -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),
|
||||
itemBuilder: (context, i) => _Zeile(
|
||||
key: ValueKey(songs[i].id),
|
||||
song: songs[i],
|
||||
alleSongs: songs,
|
||||
index: i,
|
||||
onEntfernen: () =>
|
||||
service.removeSongFromPlaylist(playlist.id, songs[i].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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Song> alleSongs;
|
||||
final int index;
|
||||
final VoidCallback onEntfernen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
final categories = context.watch<CategoryService>();
|
||||
final settings = context.watch<AppSettings>();
|
||||
final cover = categories.coverFor(song,
|
||||
groupByCategory: settings.groupCoversByCategory);
|
||||
return StreamBuilder<MediaItem?>(
|
||||
stream: handler.mediaItem,
|
||||
initialData: handler.mediaItem.valueOrNull,
|
||||
builder: (context, laufSnap) {
|
||||
final markiert = istAmLaufen(laufSnap.data, song.id);
|
||||
return StreamBuilder<PlaybackState>(
|
||||
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')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LaufBalken> createState() => _LaufBalkenState();
|
||||
}
|
||||
|
||||
class _LaufBalkenState extends State<LaufBalken>
|
||||
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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> pumpe(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider<MeloDb>.value(value: db),
|
||||
Provider<MeloAudioHandler>.value(value: handler),
|
||||
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
|
||||
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
|
||||
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SongZeile(
|
||||
song: derSong(),
|
||||
warteschlange: [derSong()],
|
||||
index: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> 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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<void> 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user