import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../library/database.dart'; import '../library/song_media.dart'; import '../player/audio_handler.dart'; import '../services/download_service.dart'; import '../services/navidrome_service.dart'; import '../shared/cover.dart'; import '../shared/lauf_balken.dart'; import '../shared/theme.dart'; /// Die Titel eines Albums oder Künstlers vom Server. /// /// Vorher spielte ein Tipp auf ein Album sofort das ganze Album ab — es gab /// keine Möglichkeit, hineinzusehen oder einen einzelnen Titel zu wählen. /// Das ist der Unterschied zwischen „abspielen" und „öffnen", und jede andere /// Musik-App trennt ihn. class ServerTitelScreen extends StatefulWidget { const ServerTitelScreen({ super.key, required this.titel, required this.navidrome, required this.holeTitel, this.untertitel, this.coverId, }); final String titel; final String? untertitel; final String? coverId; final NavidromeService navidrome; final Future> Function() holeTitel; @override State createState() => _ServerTitelScreenState(); } class _ServerTitelScreenState extends State { List? _songs; Set _geladen = const {}; String? _fehler; @override void initState() { super.initState(); _laden(); } Future _laden() async { final db = context.read(); setState(() => _fehler = null); try { final songs = await widget.holeTitel(); // Einmal für den ganzen Bildschirm, nicht je Zeile. final geladen = await db.downloadIds(); if (mounted) { setState(() { _songs = songs; _geladen = geladen; }); } } catch (e) { if (mounted) { setState(() => _fehler = e is NavidromeException ? e.message : 'Titel nicht abrufbar'); } } } /// Nach Laden oder Entfernen ist der Einmal-Schnappschuss [_geladen] veraltet /// — hier wird er nachgezogen. Future _aktualisiereGeladen() async { final geladen = await context.read().downloadIds(); if (mounted) setState(() => _geladen = geladen); } Future _offlineNehmen(SubsonicSong song) async { final dienst = context.read(); final messenger = ScaffoldMessenger.of(context); if (dienst.laeuft) { messenger.showSnackBar( const SnackBar(content: Text('Es läuft schon ein Download')), ); return; } final neu = await dienst.ladeEinzelnenTitel(song); await _aktualisiereGeladen(); if (!mounted) return; messenger.showSnackBar(SnackBar( content: Text(neu ? 'Offline: ${song.titel}' : (dienst.fehler ?? 'War schon heruntergeladen')), )); } Future _offlineEntfernen(SubsonicSong song) async { final dienst = context.read(); final messenger = ScaffoldMessenger.of(context); final weg = await dienst.entferne(song.id); await _aktualisiereGeladen(); if (!mounted) return; messenger.showSnackBar(SnackBar( content: Text(weg ? 'Vom Gerät entfernt: ${song.titel}' : (dienst.fehler ?? 'Ließ sich nicht entfernen')), )); } Future _spiele(List songs, int ab) async { final handler = context.read(); final messenger = ScaffoldMessenger.of(context); final items = [ for (final s in songs) subsonicToMediaItem(s, widget.navidrome) ]; try { final uebersprungen = await handler.loadPlaylist(items, startIndex: ab); if (uebersprungen > 0) { messenger.showSnackBar( SnackBar(content: Text(offlineHinweis(uebersprungen))), ); } } on NichtsOfflineVerfuegbar catch (e) { messenger.showSnackBar(SnackBar(content: Text('$e'))); } catch (e) { messenger.showSnackBar( SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')), ); } } Future _zufaellig(List songs) { final gemischt = [...songs]..shuffle(); return _spiele(gemischt, 0); } @override Widget build(BuildContext context) { final songs = _songs; return Scaffold( appBar: AppBar(title: Text(widget.titel)), body: _fehler != null ? _Fehler(text: _fehler!, onNochmal: _laden) : songs == null ? const Center( child: CircularProgressIndicator(color: MeloTheme.red)) : songs.isEmpty ? const Center( child: Text('Keine Titel', style: TextStyle(color: MeloTheme.text2))) : ListView.builder( itemCount: songs.length + 1, itemBuilder: (context, i) { if (i == 0) { return _Kopf( titel: widget.titel, untertitel: widget.untertitel, coverId: widget.coverId, navidrome: widget.navidrome, songs: songs, onAbspielen: () => _spiele(songs, 0), onZufaellig: () => _zufaellig(songs), ); } final song = songs[i - 1]; return _Zeile( song: song, nummer: i, geladen: _geladen.contains(song.id), onTap: () => _spiele(songs, i - 1), onOffline: () => _offlineNehmen(song), onEntfernen: () => _offlineEntfernen(song), ); }, ), ); } } /// Cover, Anzahl, Gesamtdauer und die beiden Wiedergabe-Knöpfe. class _Kopf extends StatelessWidget { const _Kopf({ required this.titel, required this.untertitel, required this.coverId, required this.navidrome, required this.songs, required this.onAbspielen, required this.onZufaellig, }); final String titel; final String? untertitel; final String? coverId; final NavidromeService navidrome; final List songs; final VoidCallback onAbspielen; final VoidCallback onZufaellig; static String _dauer(int sekunden) { final stunden = sekunden ~/ 3600; final minuten = (sekunden % 3600) ~/ 60; if (stunden > 0) return '$stunden Std. $minuten Min.'; return '$minuten Min.'; } @override Widget build(BuildContext context) { final gesamt = songs.fold(0, (s, e) => s + e.dauerSekunden); final bild = coverId; return Padding( padding: const EdgeInsets.fromLTRB( MeloSpace.md, MeloSpace.sm, MeloSpace.md, MeloSpace.sm), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ if (bild != null) CoverImage( artUri: navidrome.coverUrl(bild), size: 180, radius: MeloRadius.card, ), const SizedBox(height: MeloSpace.sm + MeloSpace.xs), Text(titel, style: Theme.of(context).textTheme.headlineSmall, textAlign: TextAlign.center), const SizedBox(height: MeloSpace.xs), Text( [ ?untertitel, '${songs.length} Titel', _dauer(gesamt), ].join(' · '), style: const TextStyle(color: MeloTheme.text2, fontSize: 13), textAlign: TextAlign.center, ), const SizedBox(height: MeloSpace.md), Row( children: [ Expanded( child: FilledButton.icon( icon: const Icon(Icons.play_arrow), label: const Text('Abspielen'), onPressed: onAbspielen, ), ), const SizedBox(width: MeloSpace.sm), Expanded( child: OutlinedButton.icon( icon: const Icon(Icons.shuffle), label: const Text('Zufällig'), onPressed: onZufaellig, ), ), ], ), const SizedBox(height: MeloSpace.sm), const Divider(height: 1), ], ), ); } } /// Eine Titelzeile: Nummer, Name, Dauer — und die Lauf-Markierung, wenn /// dieser Titel gerade spielt. class _Zeile extends StatelessWidget { const _Zeile({ required this.song, required this.nummer, required this.geladen, required this.onTap, required this.onOffline, required this.onEntfernen, }); final SubsonicSong song; final int nummer; final bool geladen; final VoidCallback onTap; final VoidCallback onOffline; final VoidCallback onEntfernen; static String _dauer(int sekunden) { final m = (sekunden ~/ 60).toString(); final s = (sekunden % 60).toString().padLeft(2, '0'); return '$m:$s'; } @override Widget build(BuildContext context) { final handler = context.read(); return StreamBuilder( stream: handler.mediaItem, initialData: handler.mediaItem.valueOrNull, builder: (context, laufSnap) { // Server-Titel tragen keine Bibliotheks-UUID; erkannt werden sie an // der Navidrome-ID. final aktuell = laufSnap.data; final markiert = aktuell != null && navidromeIdOf(aktuell) == song.id; return StreamBuilder( stream: markiert ? handler.playbackState : null, initialData: handler.playbackState.valueOrNull, builder: (context, zustandSnap) => ListTile( leading: SizedBox( width: 32, child: markiert ? LaufBalken( laeuft: zustandSnap.data?.playing ?? false, farbe: MeloTheme.red, ) : Text('$nummer', textAlign: TextAlign.center, style: const TextStyle(color: MeloTheme.text3)), ), title: Text( song.titel, maxLines: 1, overflow: TextOverflow.ellipsis, style: markiert ? const TextStyle( color: MeloTheme.red, fontWeight: FontWeight.w600) : null, ), subtitle: song.kuenstler.isEmpty ? null : Text(song.kuenstler, maxLines: 1, overflow: TextOverflow.ellipsis), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ // Bisher gab es den Lade-Knopf nur je Album — einen einzelnen // Titel mitzunehmen ging gar nicht. geladen ? IconButton( tooltip: 'Vom Gerät entfernen', icon: const Icon(Icons.download_done, size: 20, color: MeloTheme.red), onPressed: onEntfernen, ) : IconButton( tooltip: 'Offline nehmen', icon: const Icon(Icons.download_outlined, size: 20, color: MeloTheme.text3), onPressed: onOffline, ), Text(_dauer(song.dauerSekunden), style: const TextStyle(color: MeloTheme.text3)), ], ), onTap: onTap, ), ); }, ); } } class _Fehler extends StatelessWidget { const _Fehler({required this.text, required this.onNochmal}); final String text; final VoidCallback onNochmal; @override Widget build(BuildContext context) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, size: 48, color: MeloTheme.red), const SizedBox(height: MeloSpace.sm + MeloSpace.xs), Text(text, style: const TextStyle(color: MeloTheme.text2)), const SizedBox(height: MeloSpace.md), FilledButton.icon( icon: const Icon(Icons.refresh), label: const Text('Erneut versuchen'), onPressed: onNochmal, ), ], ), ); } }