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
+148 -18
View File
@@ -1,25 +1,39 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart';
import '../shared/theme.dart';
import '../shared/titel_listen_screen.dart';
import 'category_service.dart';
import 'database.dart';
import 'song_grouping.dart';
import '../shared/titel_listen_screen.dart';
import '../shared/theme.dart';
import 'song_list.dart';
/// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs].
class ArtistListScreen extends StatelessWidget {
class ArtistListScreen extends StatefulWidget {
const ArtistListScreen({super.key});
@override
State<ArtistListScreen> createState() => _ArtistListScreenState();
}
class _ArtistListScreenState extends State<ArtistListScreen> {
// Einmal anlegen, nicht in jedem build: die Zeilen hängen an
// CategoryService, und ein neuer Strom je Meldung würde die Abfrage
// während eines Scans laufend neu ausführen.
late final Stream<List<Song>> _songs = context.read<MeloDb>().watchSongs();
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return StreamBuilder<List<Song>>(
stream: db.watchSongs(),
stream: _songs,
builder: (context, snapshot) {
final songs = snapshot.data ?? const [];
if (songs.isEmpty) {
return const Center(
child: Text('Keine Künstler', style: TextStyle(color: MeloTheme.text2)),
child: Text('Keine Künstler',
style: TextStyle(color: MeloTheme.text2)),
);
}
final grouped = groupByArtist(songs);
@@ -28,18 +42,7 @@ class ArtistListScreen extends StatelessWidget {
itemCount: artists.length,
itemBuilder: (context, i) {
final artist = artists[i];
final artistSongs = grouped[artist]!;
return ListTile(
title: Text(artist, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text('${artistSongs.length} Songs'),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
TitelListenScreen(titel: artist, songs: artistSongs),
),
),
);
return _KuenstlerZeile(name: artist, songs: grouped[artist]!);
},
);
},
@@ -47,3 +50,130 @@ class ArtistListScreen extends StatelessWidget {
}
}
/// Eine Künstlerzeile. Eigenes Widget, damit nur sie an [CategoryService]
/// hängt und nicht der Bildschirm mit seinem Datenbank-Strom.
class _KuenstlerZeile extends StatelessWidget {
const _KuenstlerZeile({required this.name, required this.songs});
final String name;
final List<Song> songs;
@override
Widget build(BuildContext context) {
final kategorien = context.watch<CategoryService>();
final einstellungen = context.watch<AppSettings>();
final cover = kategorien.coverFor(songs.first,
groupByCategory: einstellungen.groupCoversByCategory);
return ListTile(
// Ein Bild statt einer leeren Fläche: die Liste war reiner Text und
// dadurch schwer zu überfliegen.
leading: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
),
title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle:
Text(kuenstlerUntertitel(songs.length, groupByAlbum(songs).length)),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => KuenstlerScreen(name: name, songs: songs),
)),
);
}
}
/// "12 Titel · 3 Alben" — die Albenzahl nur, wenn es mehr als eines ist.
/// „Titel" hat im Deutschen keine eigene Mehrzahl, deshalb ohne Fallunterscheidung.
String kuenstlerUntertitel(int titel, int alben) =>
alben > 1 ? '$titel Titel · $alben Alben' : '$titel Titel';
/// Die Seite eines Künstlers: seine Alben, darunter alle Titel.
///
/// Vorher führte ein Künstler direkt auf eine flache Titelliste — bei einem
/// Künstler mit acht Alben stand alles ungeordnet untereinander.
class KuenstlerScreen extends StatelessWidget {
const KuenstlerScreen({super.key, required this.name, required this.songs});
final String name;
final List<Song> songs;
@override
Widget build(BuildContext context) {
final alben = groupByAlbum(songs);
// Bei einem einzigen Album wäre die Zwischenstufe nur ein Klick mehr.
final zeigeAlben = alben.length > 1;
final albumListe = zeigeAlben ? alben.entries.toList() : const [];
// Kopf „Alben" + Alben + Trenner + Kopf „Alle Titel"
final vorspann = zeigeAlben ? albumListe.length + 3 : 0;
// Bewusst `builder` und nicht `ListView(children: …)`: jede Songzeile
// hört auf den Player und öffnet für ihr Herz eine eigene Abfrage. Bei
// einem Künstler mit 300 Titeln wären das 300 Abos beim Öffnen der Seite.
return Scaffold(
appBar: AppBar(title: Text(name)),
body: ListView.builder(
itemCount: vorspann + songs.length,
itemBuilder: (context, i) {
if (zeigeAlben) {
if (i == 0) return const _Kopf('Alben');
if (i <= albumListe.length) {
final eintrag = albumListe[i - 1];
return _AlbumZeile(name: eintrag.key, songs: eintrag.value);
}
if (i == albumListe.length + 1) return const Divider(height: 1);
if (i == albumListe.length + 2) return const _Kopf('Alle Titel');
}
final index = i - vorspann;
return SongZeile(
song: songs[index],
warteschlange: songs,
index: index,
);
},
),
);
}
}
class _AlbumZeile extends StatelessWidget {
const _AlbumZeile({required this.name, required this.songs});
final String name;
final List<Song> songs;
@override
Widget build(BuildContext context) {
final kategorien = context.watch<CategoryService>();
final einstellungen = context.watch<AppSettings>();
final cover = kategorien.coverFor(songs.first,
groupByCategory: einstellungen.groupCoversByCategory);
return ListTile(
leading: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
),
title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text('${songs.length} Titel'),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => TitelListenScreen(titel: name, songs: songs),
)),
);
}
}
class _Kopf extends StatelessWidget {
const _Kopf(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.md, MeloSpace.md, MeloSpace.xs),
child: Text(text.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge),
);
}
}
+8
View File
@@ -8,6 +8,14 @@ Map<String, List<Song>> groupByArtist(List<Song> songs) {
return _groupBy(songs, (s) => s.artist, unbekannterKuenstler);
}
const ohneAlbum = 'Ohne Album';
/// Gruppiert [songs] nach Album (fehlendes Album → [ohneAlbum]),
/// alphabetisch nach Albumnamen sortiert.
Map<String, List<Song>> groupByAlbum(List<Song> songs) {
return _groupBy(songs, (s) => s.album, ohneAlbum);
}
Map<String, List<Song>> _groupBy(
List<Song> songs,
String? Function(Song) keyOf,
+125 -25
View File
@@ -15,19 +15,93 @@ import 'song_detail_sheet.dart';
import '../shared/theme.dart';
import 'song_media.dart';
/// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab.
class SongList extends StatelessWidget {
const SongList(this.songs, {super.key});
final List<Song> songs;
/// Häkchen-Kreis anstelle des Covers, solange ausgewählt wird.
class _AuswahlZeichen extends StatelessWidget {
const _AuswahlZeichen({required this.ausgewaehlt});
final bool ausgewaehlt;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 48,
height: 48,
child: Center(
child: Icon(
ausgewaehlt ? Icons.check_circle : Icons.circle_outlined,
color: ausgewaehlt ? MeloTheme.red : MeloTheme.text3,
size: 26,
),
),
);
}
}
/// Höhe einer Songzeile bei normaler Schriftgröße.
///
/// 72 und nicht 64: so hoch ist ein `ListTile` mit Untertitel in Material.
/// Mit 64 schrumpften die Zeilen sichtbar, sobald der Schnellzugriff eine
/// feste Höhe erzwang — gegenüber jeder anderen Liste der App.
const songZeilenHoehe = 72.0;
/// Schriftgröße, an der die Skalierung gemessen wird: die des Titels
/// ([MeloTheme] `bodyLarge`).
const _messSchrift = 15.0;
/// Die Zeilenhöhe bei der eingestellten Schriftgröße.
///
/// Der Schnellzugriff springt über `Index × Höhe` an die richtige Stelle und
/// braucht dafür gleich hohe Zeilen. Eine fest verdrahtete Zahl würde bei
/// großer Systemschrift den Text abschneiden — deshalb mitskaliert.
///
/// Gemessen an einer **echten** Schriftgröße und nicht an 1.0: Android
/// skaliert seit Version 14 nichtlinear, kleine Größen wachsen dort stärker
/// als große. Der Faktor für 1.0 wäre viel zu groß und ließe zwischen den
/// Zeilen Löcher.
double songZeilenHoeheFuer(BuildContext context) =>
songZeilenHoehe *
(MediaQuery.textScalerOf(context).scale(_messSchrift) / _messSchrift);
/// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab.
class SongList extends StatelessWidget {
const SongList(
this.songs, {
super.key,
this.controller,
this.festeHoehe = false,
this.auswahl,
this.onAuswahlWechsel,
});
final List<Song> songs;
final ScrollController? controller;
/// Alle Zeilen gleich hoch — nötig, wenn ein Schnellzugriff exakt an eine
/// Position springen soll.
final bool festeHoehe;
/// Die gerade ausgewählten Song-IDs. `null` heißt: kein Auswahl-Modus.
/// Alle Listen, die ihn nicht anbieten, lassen beides weg und verhalten
/// sich unverändert.
final Set<String>? auswahl;
final void Function(String songId)? onAuswahlWechsel;
@override
Widget build(BuildContext context) {
final gewaehlt = auswahl;
return ListView.builder(
controller: controller,
itemExtent: festeHoehe ? songZeilenHoeheFuer(context) : null,
itemCount: songs.length,
itemBuilder: (context, i) => SongZeile(
song: songs[i],
warteschlange: songs,
index: i,
auswahlModus: gewaehlt != null && gewaehlt.isNotEmpty,
ausgewaehlt: gewaehlt?.contains(songs[i].id) ?? false,
onAuswahlWechsel: onAuswahlWechsel == null
? null
: () => onAuswahlWechsel!(songs[i].id),
),
);
}
@@ -46,12 +120,22 @@ class SongZeile extends StatelessWidget {
required this.song,
required this.warteschlange,
required this.index,
this.auswahlModus = false,
this.ausgewaehlt = false,
this.onAuswahlWechsel,
});
final Song song;
final List<Song> warteschlange;
final int index;
/// Im Auswahl-Modus wählt ein Tipp aus, statt abzuspielen.
final bool auswahlModus;
final bool ausgewaehlt;
/// Fehlt sie, bietet die Zeile keine Auswahl an — langes Drücken tut nichts.
final VoidCallback? onAuswahlWechsel;
/// "Künstler | Kategorie1 · Kategorie2" — wie im UI-Entwurf.
static String _subtitle(Song song, List<String> categories) {
final artist = song.artist ?? 'Unbekannt';
@@ -232,16 +316,23 @@ class SongZeile extends StatelessWidget {
required bool spielt,
}) {
return ListTile(
leading: LaufMarkierung(
markiert: markiert,
spielt: spielt,
radius: 6,
child: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
),
),
selected: ausgewaehlt,
selectedTileColor: MeloTheme.surfaceHigh,
// Im Auswahl-Modus tritt das Häkchen an die Stelle des Covers: eine
// zusätzliche Spalte hätte die Zeile schmaler gemacht und den Titel
// beim Umschalten springen lassen.
leading: auswahlModus
? _AuswahlZeichen(ausgewaehlt: ausgewaehlt)
: 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,
@@ -255,18 +346,27 @@ class SongZeile extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FavoriteButton(songId: song.id),
IconButton(
tooltip: 'Mehr',
icon: const Icon(Icons.more_vert),
onPressed: () => _showMenu(context),
),
],
),
// Herz und Menü gelten je einem Titel — im Auswahl-Modus wäre unklar,
// auf welchen sie sich beziehen.
trailing: auswahlModus
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
FavoriteButton(songId: song.id),
IconButton(
tooltip: 'Mehr',
icon: const Icon(Icons.more_vert),
onPressed: () => _showMenu(context),
),
],
),
onLongPress: onAuswahlWechsel,
onTap: () async {
if (auswahlModus) {
onAuswahlWechsel?.call();
return;
}
final handler = context.read<MeloAudioHandler>();
final messenger = ScaffoldMessenger.of(context);
try {
+20
View File
@@ -33,6 +33,26 @@ List<Song> sortSongs(List<Song> songs, SortMode mode, {required bool ascending})
return sorted;
}
/// Der Buchstabe, unter dem [titel] im Schnellzugriff steht.
///
/// Benutzt denselben Schlüssel wie die Sortierung — sonst zeigten die
/// Buchstaben der Leiste an Stellen, an denen gar nichts Passendes steht.
String indexBuchstabe(String titel) {
final key = _sortKey(titel);
if (key.isEmpty || !_startsWithLetter(key)) return '#';
return key[0].toUpperCase();
}
/// Erste Position je Buchstabe in einer **nach Namen sortierten** Liste.
/// Die Reihenfolge der Schlüssel entspricht der Reihenfolge in der Liste.
Map<String, int> buchstabenIndex(List<Song> sortiert) {
final index = <String, int>{};
for (var i = 0; i < sortiert.length; i++) {
index.putIfAbsent(indexBuchstabe(sortiert[i].title), () => i);
}
return index;
}
/// AZ zuerst, alles was nicht mit einem Buchstaben beginnt (Ziffern,
/// Sonderzeichen) landet unter "#" am Ende.
int _compareNames(String a, String b) {
+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;
+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.