339 lines
10 KiB
Dart
339 lines
10 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 '../player/now_playing_screen.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');
|
|
}
|
|
}
|
|
}
|
|
|
|
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 (!mounted) return;
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
|
|
);
|
|
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),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
});
|
|
|
|
final SubsonicSong song;
|
|
final int nummer;
|
|
final bool geladen;
|
|
final VoidCallback onTap;
|
|
|
|
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: [
|
|
if (geladen)
|
|
const Padding(
|
|
padding: EdgeInsets.only(right: MeloSpace.sm),
|
|
child: Icon(Icons.download_done,
|
|
size: 16, color: MeloTheme.text3),
|
|
),
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|