import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../player/audio_handler.dart'; import '../player/player_expansion_controller.dart'; import '../playlists/create_playlist_dialog.dart'; import '../settings/app_settings.dart'; import '../shared/cover.dart'; import '../shared/einblend_item.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 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? 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) => EinblendItem( index: i, child: 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 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 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 _showAddToPlaylist(BuildContext context) async { final db = context.read(); final service = context.read(); await showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( child: StreamBuilder>( 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( 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 _showMenu(BuildContext context) async { final handler = context.read(); final categories = context.read(); final settings = context.read(); 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(); final categories = context.watch(); final settings = context.watch(); final cover = categories.coverFor(song, groupByCategory: settings.groupCoversByCategory); return StreamBuilder( 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( 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(); 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; context.read().open(context); if (uebersprungen > 0) { messenger.showSnackBar( SnackBar(content: Text(offlineHinweis(uebersprungen))), ); } } catch (e) { messenger.showSnackBar( SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')), ); } }, ); } }