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:
co-authored by
Claude Opus 5
parent
0d76d8cc58
commit
490e73ce41
@@ -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 A–Z–#-Leiste am rechten Rand. Tippen und Ziehen springt zur
|
||||
/// ersten Stelle des Buchstabens.
|
||||
///
|
||||
/// Zeigt **nur** die Buchstaben, unter denen wirklich etwas steht: eine volle
|
||||
/// A–Z-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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 Z–A 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user