Zweiter Block aus der UX-Simulation (39 Funde insgesamt): - Songtext-Icon deaktiviert statt totem Knopf, wenn kein Ziel bekannt ist - Sleep-Timer-Restzeit jetzt auch im Sheet sichtbar, nicht nur im Tooltip - Drag-Handle in der Warteschlange, Wisch-nach-unten schließt den Vollbild-Player - Fehlender Tooltip am Songtext-Sheet-Schließen-Knopf ergänzt - YouTube-Download: Zeitüberschreitung meldet sich anders als toter Server - Server-Tab zeigte beim Öffnen kurz fälschlich "nicht verbunden" - Einstellungen: Favoriten-/Playlisten-Sync von "Speicher" zu "Musikserver" verschoben, Navidrome-Eintrag erklärt jetzt seinen Zweck - Kommentar in geteilte_adresse.dart korrigiert (Verhalten unverändert, dafür gibt es einen Test) Bewusst zurückgestellt: ServerFavoriteButton bleibt lokal-only (dokumentierte Architektur-Entscheidung, siehe G3 im UX-Review), Live-Wischgeste am Mini-Player, YouTube-Download-Abbrechen, Download-Badge im inaktiven Unterreiter, Versionsnummer (neue Abhängigkeit nötig). 539 Tests grün (vorher 537), flutter analyze ohne Befund.
143 lines
4.8 KiB
Dart
143 lines
4.8 KiB
Dart
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../shared/cover.dart';
|
|
import '../shared/lauf_balken.dart';
|
|
import '../shared/theme.dart';
|
|
import 'audio_handler.dart';
|
|
|
|
/// Warteschlangen-Ansicht: aktuelle Queue anzeigen, zu einem Titel springen,
|
|
/// Titel entfernen oder per Drag & Drop umsortieren.
|
|
///
|
|
/// Der `MeloAudioHandler` mutiert die Warteschlange direkt
|
|
/// ([MeloAudioHandler.removeQueueItemAt], [MeloAudioHandler.moveQueueItem]) —
|
|
/// die laufende Wiedergabe bleibt dabei unangetastet.
|
|
class QueueScreen extends StatelessWidget {
|
|
const QueueScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final handler = context.read<MeloAudioHandler>();
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Warteschlange')),
|
|
body: StreamBuilder<List<MediaItem>>(
|
|
stream: handler.queue,
|
|
builder: (context, queueSnap) {
|
|
final queue = queueSnap.data ?? const <MediaItem>[];
|
|
if (queue.isEmpty) {
|
|
return const Center(
|
|
child: Text('Warteschlange ist leer',
|
|
style: TextStyle(color: MeloTheme.text2)),
|
|
);
|
|
}
|
|
return StreamBuilder<MediaItem?>(
|
|
stream: handler.mediaItem,
|
|
builder: (context, currentSnap) {
|
|
final currentId = currentSnap.data?.id;
|
|
return ReorderableListView.builder(
|
|
itemCount: queue.length,
|
|
onReorderItem: (oldIndex, newIndex) =>
|
|
handler.moveQueueItem(oldIndex, newIndex),
|
|
itemBuilder: (context, i) => _Zeile(
|
|
key: ValueKey(queue[i].id),
|
|
index: i,
|
|
item: queue[i],
|
|
laeuft: queue[i].id == currentId,
|
|
onSpringen: () => handler.skipToQueueItem(i),
|
|
onEntfernen: () => handler.removeQueueItemAt(i),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Eine Zeile der Warteschlange.
|
|
///
|
|
/// Eigene Klasse, damit der Wiedergabe-Zustand (läuft / pausiert) hier
|
|
/// beobachtet wird statt in einem dritten `StreamBuilder`, der die ganze
|
|
/// Liste umschließt und bei jedem Pausieren neu aufbaut.
|
|
class _Zeile extends StatelessWidget {
|
|
const _Zeile({
|
|
super.key,
|
|
required this.index,
|
|
required this.item,
|
|
required this.laeuft,
|
|
required this.onSpringen,
|
|
required this.onEntfernen,
|
|
});
|
|
|
|
/// Position in der Warteschlange — nötig für das Drag-Handle.
|
|
final int index;
|
|
final MediaItem item;
|
|
|
|
/// Dieser Eintrag steht in der Wiedergabe — auch pausiert.
|
|
final bool laeuft;
|
|
|
|
final VoidCallback onSpringen;
|
|
final VoidCallback onEntfernen;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final handler = context.read<MeloAudioHandler>();
|
|
// Nur die laufende Zeile hört wirklich zu — der Zustand meldet auch den
|
|
// Pufferstand und feuert mehrmals pro Sekunde. Der StreamBuilder bleibt
|
|
// aber stehen, damit die Zeile beim Titelwechsel nicht ihre Form ändert
|
|
// und dabei ihr Coverbild neu laden muss.
|
|
return StreamBuilder<PlaybackState>(
|
|
stream: laeuft ? handler.playbackState : null,
|
|
initialData: handler.playbackState.valueOrNull,
|
|
builder: (context, zustandSnap) =>
|
|
_kachel(spielt: laeuft && (zustandSnap.data?.playing ?? false)),
|
|
);
|
|
}
|
|
|
|
Widget _kachel({required bool spielt}) {
|
|
return ListTile(
|
|
// Dasselbe Zeichen wie in der Bibliothek: Balken über dem Cover,
|
|
// damit man dieselbe Stelle wiedererkennt.
|
|
leading: LaufMarkierung(
|
|
markiert: laeuft,
|
|
spielt: spielt,
|
|
radius: 6,
|
|
child: CoverImage(artUri: item.artUri, size: 48, radius: 6),
|
|
),
|
|
title: Text(
|
|
item.title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: laeuft
|
|
? const TextStyle(
|
|
color: MeloTheme.red, fontWeight: FontWeight.bold)
|
|
: null,
|
|
),
|
|
subtitle: Text(item.artist ?? 'Unbekannt',
|
|
maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
// Ohne sichtbares Handle war Umsortieren nur per unmarkiertem
|
|
// Long-Press-and-Drag irgendwo auf der Zeile auffindbar.
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Aus Warteschlange entfernen',
|
|
icon: const Icon(Icons.close),
|
|
onPressed: onEntfernen,
|
|
),
|
|
ReorderableDragStartListener(
|
|
index: index,
|
|
child: const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 4),
|
|
child: Icon(Icons.drag_handle, color: MeloTheme.text3),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
onTap: onSpringen,
|
|
);
|
|
}
|
|
}
|