import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../player/audio_handler.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'; /// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab. class SongList extends StatelessWidget { const SongList(this.songs, {super.key}); final List songs; @override Widget build(BuildContext context) { return ListView.builder( itemCount: songs.length, itemBuilder: (context, i) => SongZeile( song: songs[i], warteschlange: songs, index: i, ), ); } } /// 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, }); final Song song; final List warteschlange; final int index; /// "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( leading: 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, ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ FavoriteButton(songId: song.id), IconButton( tooltip: 'Mehr', icon: const Icon(Icons.more_vert), onPressed: () => _showMenu(context), ), ], ), onTap: () async { 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 (uebersprungen > 0) { messenger.showSnackBar( SnackBar(content: Text(offlineHinweis(uebersprungen))), ); } } catch (e) { messenger.showSnackBar( SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')), ); } }, ); } }