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
+107
View File
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'theme.dart';
/// Ab welcher Länge sich der Schnellzugriff lohnt.
///
/// Darunter scrollt man in zwei Wischern durch — die Leiste wäre dann nur
/// eine schmale Fläche, die Platz nimmt und Fehltreffer produziert.
const alphabetAb = 30;
/// Wie viele **verschiedene** Anfangsbuchstaben es mindestens braucht.
///
/// Ein Album mit 40 Titeln „Track 01…40" hat genau einen — eine Leiste mit
/// einem Buchstaben ist kein Schnellzugriff, sondern nur eine Fläche.
const alphabetMindestBuchstaben = 3;
/// Feste Breite der Leiste.
///
/// Bewusst fest und nicht mitwachsend: vorher skalierte jeder Buchstabe auf
/// die volle Höhe seines Platzes, die Leiste wurde dadurch **breiter**, je
/// weniger Buchstaben es gab — bei einem einzigen schob sie die Songliste
/// vollständig aus dem Bild.
const alphabetBreite = 24.0;
/// Schmale AZ#-Leiste am rechten Rand. Tippen und Ziehen springt zur
/// ersten Stelle des Buchstabens.
///
/// Zeigt **nur** die Buchstaben, unter denen wirklich etwas steht: eine volle
/// AZ-Leiste in einer Bibliothek mit vier Künstlern wäre zum größten Teil
/// tote Fläche.
class AlphabetLeiste extends StatefulWidget {
const AlphabetLeiste({
super.key,
required this.buchstaben,
required this.onGewaehlt,
});
final List<String> buchstaben;
final ValueChanged<String> onGewaehlt;
@override
State<AlphabetLeiste> createState() => _AlphabetLeisteState();
}
class _AlphabetLeisteState extends State<AlphabetLeiste> {
String? _beruehrt;
void _waehleBei(double y, double hoehe) {
if (widget.buchstaben.isEmpty) return;
final proBuchstabe = hoehe / widget.buchstaben.length;
final index =
(y / proBuchstabe).floor().clamp(0, widget.buchstaben.length - 1);
final buchstabe = widget.buchstaben[index];
if (buchstabe == _beruehrt) return;
setState(() => _beruehrt = buchstabe);
widget.onGewaehlt(buchstabe);
}
@override
Widget build(BuildContext context) {
if (widget.buchstaben.isEmpty) return const SizedBox.shrink();
return SizedBox(
width: alphabetBreite,
child: LayoutBuilder(
builder: (context, raum) => GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragStart: (d) =>
_waehleBei(d.localPosition.dy, raum.maxHeight),
onVerticalDragUpdate: (d) =>
_waehleBei(d.localPosition.dy, raum.maxHeight),
onVerticalDragEnd: (_) => setState(() => _beruehrt = null),
onVerticalDragCancel: () => setState(() => _beruehrt = null),
onTapDown: (d) => _waehleBei(d.localPosition.dy, raum.maxHeight),
onTapUp: (_) => setState(() => _beruehrt = null),
child: Semantics(
label: 'Schnellzugriff nach Anfangsbuchstabe',
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: MeloSpace.xs),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final buchstabe in widget.buchstaben)
Expanded(
child: Center(
child: Text(
buchstabe,
style: TextStyle(
fontSize: 11,
fontWeight: buchstabe == _beruehrt
? FontWeight.w700
: FontWeight.w500,
color: buchstabe == _beruehrt
? MeloTheme.red
: MeloTheme.text3,
),
),
),
),
],
),
),
),
),
),
);
}
}
+33
View File
@@ -0,0 +1,33 @@
/// Die Auswahl, nachdem [songId] angetippt wurde — ein Titel kommt dazu oder
/// fällt heraus.
///
/// Gibt eine neue Menge zurück statt die alte zu ändern: die Oberfläche
/// vergleicht beide, um zu erkennen, ob sich etwas getan hat.
Set<String> auswahlNach(Set<String> auswahl, String songId) {
final neu = {...auswahl};
if (!neu.remove(songId)) neu.add(songId);
return neu;
}
/// Beschriftung der Auswahl-Leiste.
String auswahlText(int anzahl) => switch (anzahl) {
0 => 'Titel auswählen',
1 => '1 Titel ausgewählt',
_ => '$anzahl ausgewählt',
};
/// Rückmeldung nach dem Hinzufügen mehrerer Titel zu einer Wiedergabeliste.
///
/// Nennt beides: was dazukam und was schon drin war. Vorher meldete die App
/// die volle Auswahl als „hinzugefügt", obwohl bereits enthaltene Titel wegen
/// des Primärschlüssels nur verschoben worden wären.
String playlistMeldung({
required int neu,
required int schonDa,
String? fehler,
}) {
if (fehler != null) return 'Abgebrochen nach $neu Titeln: $fehler';
if (neu == 0) return 'Alle schon in der Liste';
if (schonDa == 0) return '$neu Titel hinzugefügt';
return '$neu hinzugefügt · $schonDa waren schon drin';
}
+241 -2
View File
@@ -5,9 +5,13 @@ import '../library/database.dart';
import '../library/song_list.dart';
import '../library/song_media.dart';
import '../library/category_service.dart';
import '../library/playlist_service.dart';
import '../library/song_sort.dart';
import '../player/audio_handler.dart';
import '../settings/app_settings.dart';
import '../playlists/create_playlist_dialog.dart';
import 'alphabet_leiste.dart';
import 'auswahl.dart';
import 'sort_store.dart';
import 'theme.dart';
@@ -38,6 +42,126 @@ class _SortableSongListState extends State<SortableSongList> {
ascending: false,
);
final _scroll = ScrollController();
/// Song-IDs der gerade ausgewählten Titel. Leer heißt: kein Auswahl-Modus.
Set<String> _auswahl = const {};
bool get _waehltAus => _auswahl.isNotEmpty;
void _wechsleAuswahl(String songId) =>
setState(() => _auswahl = auswahlNach(_auswahl, songId));
void _beendeAuswahl() => setState(() => _auswahl = const {});
/// Die ausgewählten Titel in der Reihenfolge der angezeigten Liste.
List<Song> _gewaehlte(List<Song> sorted) =>
[for (final s in sorted) if (_auswahl.contains(s.id)) s];
Future<void> _inWiedergabeliste(List<Song> gewaehlte) async {
final db = context.read<MeloDb>();
final dienst = context.read<PlaylistService>();
final messenger = ScaffoldMessenger.of(context);
final ziel = await showModalBottomSheet<String>(
context: context,
builder: (sheetContext) => SafeArea(
child: StreamBuilder<List<Playlist>>(
stream: db.watchPlaylists(),
builder: (context, snapshot) {
final listen = snapshot.data ?? const <Playlist>[];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.add),
title: const Text('Neue Wiedergabeliste'),
onTap: () async {
final neu = await showDialog<String?>(
context: sheetContext,
builder: (_) => const CreatePlaylistDialog(),
);
if (neu != null && sheetContext.mounted) {
Navigator.pop(sheetContext, neu);
}
},
),
for (final liste in listen)
ListTile(
leading: const Icon(Icons.playlist_play),
title: Text(liste.name),
onTap: () => Navigator.pop(sheetContext, liste.id),
),
],
);
},
),
),
);
if (ziel == null) return;
final vorhanden = await db.watchPlaylistSongs(ziel).first;
// Wer schon drin ist, bleibt, wo er ist. Ein erneutes Einfügen würde ihn
// wegen des Primärschlüssels nur verschieben und Lücken hinterlassen.
final bekannt = {for (final s in vorhanden) s.id};
var position = vorhanden.length;
var neu = 0;
String? fehler;
for (final song in gewaehlte) {
if (bekannt.contains(song.id)) continue;
try {
await dienst.addSongToPlaylist(ziel, song.id, position++);
neu++;
} catch (e) {
fehler = '$e';
break;
}
}
if (!mounted) return;
_beendeAuswahl();
messenger.showSnackBar(SnackBar(
content: Text(playlistMeldung(
neu: neu,
schonDa: gewaehlte.length - neu,
fehler: fehler,
)),
));
}
Future<void> _inWarteschlange(List<Song> gewaehlte) async {
final handler = context.read<MeloAudioHandler>();
final categories = context.read<CategoryService>();
final settings = context.read<AppSettings>();
final messenger = ScaffoldMessenger.of(context);
var angehaengt = 0;
for (final song in gewaehlte) {
final ok = await handler.addToQueue(songToMediaItem(
song,
cover: categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory),
));
if (ok) angehaengt++;
}
if (!mounted) return;
_beendeAuswahl();
messenger.showSnackBar(SnackBar(
content: Text(angehaengt == gewaehlte.length
? '$angehaengt Titel in der Warteschlange'
: '$angehaengt von ${gewaehlte.length} Titeln angehängt'),
));
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
void _springeZu(int index) {
if (!_scroll.hasClients) return;
final ziel = index * songZeilenHoeheFuer(context);
_scroll.jumpTo(ziel.clamp(0.0, _scroll.position.maxScrollExtent));
}
@override
void initState() {
super.initState();
@@ -87,6 +211,51 @@ class _SortableSongListState extends State<SortableSongList> {
await SortStore.save(widget.storeKey, chosen);
}
/// Die Liste, bei Namenssortierung mit Schnellzugriff daneben.
///
/// Nur dort: bei „Zuletzt hinzugefügt" oder „Wie oft abgespielt" stünden
/// die Buchstaben in willkürlicher Reihenfolge und führten ins Leere.
Widget _liste(List<Song> sorted) {
final index = _setting.mode == SortMode.name && sorted.length >= alphabetAb
? buchstabenIndex(sorted)
: const <String, int>{};
// Auch genug **verschiedene** Buchstaben: bei einem Album mit 40 Titeln
// „Track 01…40" gäbe es nur einen, und die Leiste wäre kein Sprungziel.
final mitLeiste = index.length >= alphabetMindestBuchstaben;
if (!mitLeiste) {
return SongList(
sorted,
controller: _scroll,
auswahl: _auswahl,
onAuswahlWechsel: _wechsleAuswahl,
);
}
// Der Index steht oben — er wird auf der **angezeigten** Liste gebaut,
// nicht auf einer gedachten aufsteigenden: bei ZA stehen die Buchstaben
// dann von unten nach oben in der Leiste, und ein Sprung landet am Anfang
// des Blocks statt an seinem Ende.
return Row(
children: [
Expanded(
child: SongList(
sorted,
controller: _scroll,
festeHoehe: true,
auswahl: _auswahl,
onAuswahlWechsel: _wechsleAuswahl,
),
),
AlphabetLeiste(
buchstaben: index.keys.toList(),
onGewaehlt: (b) {
final pos = index[b];
if (pos != null) _springeZu(pos);
},
),
],
);
}
@override
Widget build(BuildContext context) {
final sorted = sortSongs(
@@ -94,8 +263,24 @@ class _SortableSongListState extends State<SortableSongList> {
_setting.mode,
ascending: _setting.ascending,
);
return Column(
return PopScope(
// Zurück beendet zuerst die Auswahl. Sonst verlöre man zwanzig
// markierte Titel mit einer Geste, die überall sonst „einen Schritt
// zurück" bedeutet.
canPop: !_waehltAus,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && _waehltAus) _beendeAuswahl();
},
child: Column(
children: [
if (_waehltAus)
_AuswahlLeiste(
anzahl: _auswahl.length,
onAbbrechen: _beendeAuswahl,
onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)),
onWarteschlange: () => _inWarteschlange(_gewaehlte(sorted)),
)
else
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 8, 4),
child: Row(
@@ -145,9 +330,63 @@ class _SortableSongListState extends State<SortableSongList> {
Expanded(
child: sorted.isEmpty
? (widget.empty ?? const SizedBox.shrink())
: SongList(sorted),
: _liste(sorted),
),
],
),
);
}
}
/// Kopfzeile im Auswahl-Modus: Anzahl und die beiden Stapel-Aktionen.
///
/// Sie ersetzt die Shuffle-Zeile, statt darunter zu erscheinen — sonst
/// rutschte die Liste beim Auswählen nach unten weg.
class _AuswahlLeiste extends StatelessWidget {
const _AuswahlLeiste({
required this.anzahl,
required this.onAbbrechen,
required this.onWiedergabeliste,
required this.onWarteschlange,
});
final int anzahl;
final VoidCallback onAbbrechen;
final VoidCallback onWiedergabeliste;
final VoidCallback onWarteschlange;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.sm, MeloSpace.xs, MeloSpace.sm, MeloSpace.xs),
child: Row(
children: [
IconButton(
tooltip: 'Auswahl beenden',
icon: const Icon(Icons.close),
onPressed: onAbbrechen,
),
Expanded(
child: Text(
auswahlText(anzahl),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
IconButton(
tooltip: 'Zur Warteschlange hinzufügen',
icon: const Icon(Icons.queue),
onPressed: onWarteschlange,
),
IconButton(
tooltip: 'Zu Wiedergabeliste hinzufügen',
icon: const Icon(Icons.playlist_add),
onPressed: onWiedergabeliste,
),
],
),
);
}
}
+2 -2
View File
@@ -65,7 +65,7 @@ class _Reiter extends StatelessWidget {
child: InkWell(
onTap: onTap,
child: AnimatedContainer(
duration: MeloMotion.fast,
duration: MeloMotion.ruhig(context, MeloMotion.fast),
curve: MeloMotion.curve,
// Volle 48 dp hoch: darunter trifft man den Reiter im Gehen nicht
// zuverlässig (Material 3, Mindestgröße für Bedienelemente).
@@ -83,7 +83,7 @@ class _Reiter extends StatelessWidget {
border: aktiv ? null : MeloTheme.hairline,
),
child: AnimatedDefaultTextStyle(
duration: MeloMotion.fast,
duration: MeloMotion.ruhig(context, MeloMotion.fast),
curve: MeloMotion.curve,
style: TextStyle(
color: aktiv ? MeloTheme.black : MeloTheme.text2,
+11
View File
@@ -46,6 +46,17 @@ abstract final class MeloMotion {
static const Duration normal = Duration(milliseconds: 220);
static const Curve curve = Curves.easeOutCubic;
/// [dauer], oder null, wenn der Nutzer Systemanimationen abgeschaltet hat.
///
/// Diese Einstellung nutzen Menschen, denen Bewegung auf dem Bildschirm
/// Unwohlsein bereitet. Die Übergänge sollen dann nicht schneller sein,
/// sondern gar nicht stattfinden — der Zustand wechselt schlicht.
///
/// Bewusst kein globaler Schalter im Theme: `MaterialApp` kennt keinen, und
/// jede animierte Stelle muss die Entscheidung ohnehin selbst treffen.
static Duration ruhig(BuildContext context, Duration dauer) =>
MediaQuery.disableAnimationsOf(context) ? Duration.zero : dauer;
}
/// Melo-Theme: Schwarz + Rot.