Files
Melo/lib/player/queue_screen.dart
Hermes (Server) 5645660a2d UX-Simulation, Teil 2: Player-Feinschliff, Downloads, Einstellungen
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.
2026-08-25 09:49:47 +02:00

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,
);
}
}