Politur: Wischen, wandernde Cover, Alphabet-Sprung, Mehrfachauswahl

Die P2-Liste aus dem Review — Kleinigkeiten, die man einzeln nicht
vermisst und zusammen sofort merkt.

- Das Coverbild wandert beim Oeffnen der Vollbild-Wiedergabe an seinen
  Platz, statt dass ein neuer Bildschirm aufspringt.
- Wischen am Mini-Player: nach oben oeffnen, seitwaerts Titelwechsel. Mit
  Schwelle, weil der Mini-Player zugleich ein Knopf ist und der Finger
  beim Tippen fast immer ein paar Pixel wandert. Nach unten bewusst
  unbelegt, damit niemand die Wiedergabe versehentlich wegschiebt.
- Kuenstlerliste mit Bildern und Albenzahl; dahinter eine Kuenstlerseite
  mit seinen Alben und darunter allen Titeln (dafuer neu: groupByAlbum).
  Bei nur einem Album entfaellt die Zwischenstufe.
- Alphabet-Sprung am rechten Rand, nur bei Namenssortierung und ab 30
  Titeln. Umlaute zaehlen zu ihrem Grundbuchstaben, Ziffern und Zeichen
  unter "#" — dieselbe Regel wie die Sortierung.
- Mehrfachauswahl per langem Druck, mit "Zur Warteschlange" und "Zu
  Wiedergabeliste".
- Abgeschaltete Systemanimationen gelten jetzt ueberall, nicht nur bei den
  Lauf-Balken.
- Querformat und Tablet: der Player legt Cover und Bedienung nebeneinander
  und begrenzt die Textbreite.

Beim Bauen gefunden: der Alphabet-Sprung landete bei absteigender
Sortierung am Ende eines Buchstaben-Blocks statt an seinem Anfang.

Aus dem Code-Review nachgebessert, sieben Punkte: die Alphabet-Leiste
wurde breiter, je weniger Buchstaben sie hatte (bei einem einzigen 528 dp
— die Songliste waere verschwunden); die neue Kuenstlerseite baute alle
Titel sofort und oeffnete damit bei 300 Titeln ebenso viele Abos; die
Bildlauf-Animation im Songtext stuerzte bei abgeschalteten
Systemanimationen ab; die feste Zeilenhoehe war 64 statt 72 und die
Schriftskalierung an 1.0 statt an einer echten Groesse gemessen; bereits
enthaltene Titel wurden in Wiedergabelisten verschoben statt uebersprungen
und trotzdem mitgezaehlt; ein Fehler im Stapel brach still ab; und die
Zurueck-Geste verliess den Bildschirm, statt die Auswahl zu beenden.

Selbst gefunden: in der Kuenstlerliste hing der Datenbank-Strom an
CategoryService und wurde bei jeder Kategorie-Meldung neu aufgesetzt.

Nebenbei aufgeklaert, warum ein neuer Widget-Test endlos hing: Aufbau in
setUp laeuft ausserhalb der Testzone, die drift-Stroeme liefen dort in
echter Zeit, waehrend pumpAndSettle in Testzeit auf sie wartete. Aufbau in
den Testkoerper verschoben. Der aeltere Haenger im BACKLOG hat eine andere
Ursache (db.close() dort nachweislich ausgeschlossen); die Spur ist
notiert.

500 Tests gruen (vorher 457), 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:
Hermes (Server)
2026-08-24 15:09:46 +02:00
co-authored by Claude Opus 5
parent 0d76d8cc58
commit 490e73ce41
22 changed files with 1467 additions and 116 deletions
+63 -36
View File
@@ -6,6 +6,7 @@ import '../shared/cover.dart';
import '../shared/theme.dart';
import 'audio_handler.dart';
import 'now_playing_screen.dart';
import 'wischen.dart';
/// Kompakte Wiedergabe-Leiste über der Bottom-Nav.
/// Blendet sich aus, wenn nichts läuft.
@@ -25,7 +26,7 @@ class MiniPlayer extends StatelessWidget {
// Ein- und Ausblenden statt Aufpoppen: die Leiste kommt von unten
// dazu, wenn etwas startet.
return AnimatedSwitcher(
duration: MeloMotion.normal,
duration: MeloMotion.ruhig(context, MeloMotion.normal),
switchInCurve: MeloMotion.curve,
transitionBuilder: (kind, animation) => SizeTransition(
sizeFactor: animation,
@@ -47,6 +48,10 @@ class _Leiste extends StatelessWidget {
final MeloAudioHandler handler;
final MediaItem item;
void _oeffne(BuildContext context) => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
);
@override
Widget build(BuildContext context) {
final texte = Theme.of(context).textTheme;
@@ -61,42 +66,64 @@ class _Leiste extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const NowPlayingScreen(),
)),
child: SizedBox(
height: MiniPlayer.hoehe,
child: Row(
children: [
const SizedBox(width: MeloSpace.md),
CoverImage(artUri: item.artUri, size: 52),
const SizedBox(width: MeloSpace.md),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: texte.bodyLarge
?.copyWith(fontWeight: FontWeight.w600),
),
Text(
item.artist ?? 'Unbekannt',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: texte.bodySmall,
),
],
GestureDetector(
// Wischen wie in jeder anderen Musik-App: nach oben öffnet die
// Vollbild-Wiedergabe, seitwärts wechselt den Titel.
onVerticalDragEnd: (details) {
if (oeffnetVollbild(details.velocity.pixelsPerSecond.dy)) {
_oeffne(context);
}
},
onHorizontalDragEnd: (details) {
switch (titelWechsel(details.velocity.pixelsPerSecond.dx)) {
case Wechsel.naechster:
handler.skipToNext();
case Wechsel.vorheriger:
handler.skipToPrevious();
case null:
break;
}
},
child: InkWell(
onTap: () => _oeffne(context),
child: SizedBox(
height: MiniPlayer.hoehe,
child: Row(
children: [
const SizedBox(width: MeloSpace.md),
// Dasselbe Bild wie im Vollbild: es wandert beim Öffnen
// dorthin, statt dass ein neuer Bildschirm aufspringt.
Hero(
tag: coverHeldenName,
child: CoverImage(artUri: item.artUri, size: 52),
),
),
const SizedBox(width: MeloSpace.sm),
_Bedienung(handler: handler),
const SizedBox(width: MeloSpace.sm),
],
const SizedBox(width: MeloSpace.md),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: texte.bodyLarge
?.copyWith(fontWeight: FontWeight.w600),
),
Text(
item.artist ?? 'Unbekannt',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: texte.bodySmall,
),
],
),
),
const SizedBox(width: MeloSpace.sm),
_Bedienung(handler: handler),
const SizedBox(width: MeloSpace.sm),
],
),
),
),
),
+104 -31
View File
@@ -75,30 +75,54 @@ class NowPlayingScreen extends StatelessWidget {
if (item == null) {
return const Center(child: Text('Nichts in Wiedergabe'));
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
Expanded(child: Center(child: _Cover(item: item))),
const SizedBox(height: 24),
Text(
item.title,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
item.artist ?? 'Unbekannt',
style: const TextStyle(color: MeloTheme.text2),
),
const SizedBox(height: 24),
_ProgressBar(handler: handler),
const _Controls(),
const SizedBox(height: 16),
],
),
return LayoutBuilder(
builder: (context, raum) {
// Im Querformat ist die Höhe knapp: Cover und Bedienung
// untereinander ließen vom Cover einen Streifen übrig oder
// schoben die Knöpfe aus dem Bild. Nebeneinander bleibt beides
// benutzbar. Dieselbe Aufteilung hilft auf einem Tablet.
final nebeneinander = raum.maxWidth > raum.maxHeight;
final inhalt = _Angaben(item: item, handler: handler);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: nebeneinander
? Row(
children: [
Expanded(
child: Center(child: _Cover(item: item)),
),
const SizedBox(width: MeloSpace.lg),
// Der Inhalt bekommt eine feste Obergrenze: auf
// einem breiten Bildschirm zerrte er sonst über
// die ganze Breite und wäre schlecht lesbar.
Expanded(
child: Center(
child: ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: 420),
child: inhalt,
),
),
),
],
)
: Column(
children: [
Expanded(
child: Center(child: _Cover(item: item)),
),
const SizedBox(height: MeloSpace.lg),
Center(
child: ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: 520),
child: inhalt,
),
),
],
),
);
},
);
},
),
@@ -107,6 +131,42 @@ class NowPlayingScreen extends StatelessWidget {
}
}
/// Titel, Künstler, Fortschritt und Bedienung — der Teil, der im Querformat
/// neben das Cover rückt.
class _Angaben extends StatelessWidget {
const _Angaben({required this.item, required this.handler});
final MediaItem item;
final MeloAudioHandler handler;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.title,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: MeloSpace.sm),
Text(
item.artist ?? 'Unbekannt',
style: const TextStyle(color: MeloTheme.text2),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: MeloSpace.lg),
_ProgressBar(handler: handler),
const _Controls(),
const SizedBox(height: MeloSpace.md),
],
);
}
}
/// Das passende Herz für den laufenden Titel.
class _Herz extends StatelessWidget {
const _Herz({required this.item});
@@ -131,6 +191,11 @@ class _Herz extends StatelessWidget {
}
}
/// Gemeinsamer Name des Coverbilds in Mini-Player und Vollbild — daran
/// erkennt Flutter, dass es dasselbe Bild ist, und lässt es zwischen beiden
/// wandern statt einen neuen Bildschirm aufspringen zu lassen.
const coverHeldenName = 'melo-cover';
class _Cover extends StatelessWidget {
const _Cover({required this.item});
final MediaItem item;
@@ -139,7 +204,10 @@ class _Cover extends StatelessWidget {
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1,
child: CoverImage(artUri: item.artUri, radius: 16),
child: Hero(
tag: coverHeldenName,
child: CoverImage(artUri: item.artUri, radius: 16),
),
);
}
}
@@ -403,11 +471,16 @@ class _LyricsSheetState extends State<_LyricsSheet> {
final ziel = (index * _zeilenHoehe) -
(_scroll.position.viewportDimension / 2) +
(_zeilenHoehe / 2);
_scroll.animateTo(
ziel.clamp(0, _scroll.position.maxScrollExtent),
duration: MeloMotion.normal,
curve: MeloMotion.curve,
);
final wohin = ziel.clamp(0.0, _scroll.position.maxScrollExtent);
final dauer = MeloMotion.ruhig(context, MeloMotion.normal);
// Bei abgeschalteten Systemanimationen wird die Dauer null — `animateTo`
// wirft dann eine Zusicherung. Ohne Bewegung ist Springen ohnehin das,
// was die Einstellung meint.
if (dauer == Duration.zero) {
_scroll.jumpTo(wohin);
return;
}
_scroll.animateTo(wohin, duration: dauer, curve: MeloMotion.curve);
}
static const _zeilenHoehe = 40.0;
@@ -509,7 +582,7 @@ class _Mitlaufend extends StatelessWidget {
child: Align(
alignment: Alignment.centerLeft,
child: AnimatedDefaultTextStyle(
duration: MeloMotion.fast,
duration: MeloMotion.ruhig(context, MeloMotion.fast),
style: TextStyle(
fontSize: istAktiv ? 17 : 15,
height: 1.3,
+25
View File
@@ -0,0 +1,25 @@
/// Was eine waagerechte Wischbewegung am Mini-Player bewirkt.
enum Wechsel { vorheriger, naechster }
/// Ab welcher Wisch-Geschwindigkeit (Pixel pro Sekunde) eine Geste zählt.
///
/// Bewusst nicht null: der Mini-Player ist zugleich ein Knopf, und beim
/// Antippen wandert der Finger fast immer ein paar Pixel. Ohne Schwelle
/// spränge bei jedem zweiten Tipp der Titel.
const wischSchwelle = 300.0;
/// Zu welchem Titel eine waagerechte Bewegung führt, oder `null`, wenn sie
/// zu zaghaft war.
///
/// Nach links wischen holt den nächsten Titel herein — so, wie man ein Blatt
/// zur Seite schiebt und das darunter sichtbar wird.
Wechsel? titelWechsel(double geschwindigkeit) {
if (geschwindigkeit.abs() <= wischSchwelle) return null;
return geschwindigkeit < 0 ? Wechsel.naechster : Wechsel.vorheriger;
}
/// Ob eine senkrechte Bewegung die Vollbild-Wiedergabe öffnet.
/// Nur nach oben — nach unten ist bewusst unbelegt, damit niemand die
/// Wiedergabe aus Versehen wegschiebt.
bool oeffnetVollbild(double geschwindigkeit) =>
geschwindigkeit < -wischSchwelle;