Files
Melo/lib/downloads/server_titel_screen.dart
T

389 lines
12 KiB
Dart

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<List<SubsonicSong>> Function() holeTitel;
@override
State<ServerTitelScreen> createState() => _ServerTitelScreenState();
}
class _ServerTitelScreenState extends State<ServerTitelScreen> {
List<SubsonicSong>? _songs;
Set<String> _geladen = const {};
String? _fehler;
@override
void initState() {
super.initState();
_laden();
}
Future<void> _laden() async {
final db = context.read<MeloDb>();
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<void> _aktualisiereGeladen() async {
final geladen = await context.read<MeloDb>().downloadIds();
if (mounted) setState(() => _geladen = geladen);
}
Future<void> _offlineNehmen(SubsonicSong song) async {
final dienst = context.read<DownloadService>();
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<void> _offlineEntfernen(SubsonicSong song) async {
final dienst = context.read<DownloadService>();
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<void> _spiele(List<SubsonicSong> songs, int ab) async {
final handler = context.read<MeloAudioHandler>();
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<void> _zufaellig(List<SubsonicSong> 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<SubsonicSong> 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<int>(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<MeloAudioHandler>();
return StreamBuilder<MediaItem?>(
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<PlaybackState>(
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,
),
],
),
);
}
}