400 lines
14 KiB
Dart
400 lines
14 KiB
Dart
import 'package:audio_service/audio_service.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
|
||
import '../player/audio_handler.dart';
|
||
import '../player/now_playing_screen.dart';
|
||
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';
|
||
|
||
/// 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),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Eine Zeile der Songliste: Cover, Titel, Untertitel, Herz und Menü.
|
||
///
|
||
/// Eigenständig, weil die Suche einzelne Zeilen in ihre Abschnitte setzt und
|
||
/// eine ganze [SongList] dort eine verschachtelte Scroll-Fläche wäre.
|
||
///
|
||
/// [warteschlange] ist die Liste, die beim Antippen geladen wird — in der
|
||
/// Bibliothek die ganze Liste, in der Suche der Titel-Abschnitt.
|
||
class SongZeile extends StatelessWidget {
|
||
const SongZeile({
|
||
super.key,
|
||
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';
|
||
if (categories.isEmpty) return artist;
|
||
return '$artist | ${categories.join(' · ')}';
|
||
}
|
||
|
||
/// Öffnet ein Bottom-Sheet mit allen Playlisten, um [song] hinzuzufügen.
|
||
Future<void> _showAddToPlaylist(BuildContext context) async {
|
||
final db = context.read<MeloDb>();
|
||
final service = context.read<PlaylistService>();
|
||
await showModalBottomSheet(
|
||
context: context,
|
||
builder: (sheetContext) => SafeArea(
|
||
child: StreamBuilder<List<Playlist>>(
|
||
stream: db.watchPlaylists(),
|
||
builder: (context, snapshot) {
|
||
final playlists = snapshot.data ?? const [];
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ListTile(
|
||
leading: const Icon(Icons.add),
|
||
title: const Text('Neue Playlist'),
|
||
onTap: () async {
|
||
final newId = await showDialog<String?>(
|
||
context: sheetContext,
|
||
builder: (_) => const CreatePlaylistDialog(),
|
||
);
|
||
if (newId == null) return;
|
||
final position = (await db.watchPlaylistSongs(newId).first).length;
|
||
await service.addSongToPlaylist(newId, song.id, position);
|
||
if (sheetContext.mounted) {
|
||
Navigator.pop(sheetContext);
|
||
ScaffoldMessenger.of(sheetContext).showSnackBar(
|
||
const SnackBar(content: Text('Zu Playlist hinzugefügt')),
|
||
);
|
||
}
|
||
},
|
||
),
|
||
for (final playlist in playlists)
|
||
ListTile(
|
||
leading: const Icon(Icons.playlist_play),
|
||
title: Text(playlist.name),
|
||
onTap: () async {
|
||
final position =
|
||
(await db.watchPlaylistSongs(playlist.id).first).length;
|
||
await service.addSongToPlaylist(playlist.id, song.id, position);
|
||
if (sheetContext.mounted) {
|
||
Navigator.pop(sheetContext);
|
||
ScaffoldMessenger.of(sheetContext).showSnackBar(
|
||
SnackBar(content: Text('Zu ${playlist.name} hinzugefügt')),
|
||
);
|
||
}
|
||
},
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Menü hinter dem Drei-Punkte-Symbol einer Songzeile.
|
||
///
|
||
/// Die Offline-Meldungen greifen hier heute nie: diese Liste zeigt nur
|
||
/// Titel der Bibliothek, und die liegen als Datei vor. Sie stehen trotzdem
|
||
/// da, weil die Alternative — den Rückgabewert wegwerfen und "hinzugefügt"
|
||
/// melden — still lügen würde, sobald ein Server-Titel hier ankommt.
|
||
Future<void> _showMenu(BuildContext context) async {
|
||
final handler = context.read<MeloAudioHandler>();
|
||
final categories = context.read<CategoryService>();
|
||
final settings = context.read<AppSettings>();
|
||
final messenger = ScaffoldMessenger.of(context);
|
||
MediaItem item() => songToMediaItem(
|
||
song,
|
||
cover: categories.coverFor(song,
|
||
groupByCategory: settings.groupCoversByCategory),
|
||
);
|
||
await showModalBottomSheet(
|
||
context: context,
|
||
builder: (sheetContext) => SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ListTile(
|
||
leading: const Icon(Icons.info_outline),
|
||
title: const Text('Metadaten'),
|
||
onTap: () {
|
||
Navigator.pop(sheetContext);
|
||
SongDetailSheet.show(context, song);
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.playlist_play),
|
||
title: const Text('Als Nächstes spielen'),
|
||
onTap: () async {
|
||
Navigator.pop(sheetContext);
|
||
final ok = await handler.playNext(item());
|
||
messenger.showSnackBar(SnackBar(
|
||
content: Text(
|
||
ok ? 'Läuft als Nächstes' : nichtOfflineHinweis)));
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.queue),
|
||
title: const Text('Zur Warteschlange hinzufügen'),
|
||
subtitle: const Text(
|
||
'Nur für jetzt — nicht in Favoriten oder Wiedergabelisten'),
|
||
onTap: () async {
|
||
Navigator.pop(sheetContext);
|
||
final ok = await handler.addToQueue(item());
|
||
messenger.showSnackBar(SnackBar(
|
||
content: Text(ok
|
||
? 'Zur Warteschlange hinzugefügt'
|
||
: nichtOfflineHinweis)));
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.playlist_add),
|
||
title: const Text('Zu Wiedergabeliste hinzufügen'),
|
||
onTap: () {
|
||
Navigator.pop(sheetContext);
|
||
_showAddToPlaylist(context);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
// 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(
|
||
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,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: markiert
|
||
? const TextStyle(color: MeloTheme.red, fontWeight: FontWeight.w600)
|
||
: null,
|
||
),
|
||
subtitle: Text(
|
||
_subtitle(song, categories.of(song.id)),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
// 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;
|
||
}
|
||
FocusScope.of(context).unfocus();
|
||
final handler = context.read<MeloAudioHandler>();
|
||
final messenger = ScaffoldMessenger.of(context);
|
||
try {
|
||
final uebersprungen = await playSongs(
|
||
handler,
|
||
warteschlange,
|
||
index,
|
||
coverOf: (s) => categories.coverFor(s,
|
||
groupByCategory: settings.groupCoversByCategory),
|
||
);
|
||
if (!context.mounted) return;
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
|
||
);
|
||
if (uebersprungen > 0) {
|
||
messenger.showSnackBar(
|
||
SnackBar(content: Text(offlineHinweis(uebersprungen))),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
messenger.showSnackBar(
|
||
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
|
||
);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
}
|