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.
This commit is contained in:
Hermes (Server)
2026-08-25 09:49:47 +02:00
parent 07acf77ba4
commit 5645660a2d
8 changed files with 186 additions and 40 deletions
+29 -7
View File
@@ -14,6 +14,7 @@ import 'audio_handler.dart';
import 'cover_farbe.dart';
import 'lrc.dart';
import 'queue_screen.dart';
import 'wischen.dart';
/// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls.
class NowPlayingScreen extends StatelessWidget {
@@ -44,17 +45,21 @@ class NowPlayingScreen extends StatelessWidget {
builder: (context, snapshot) {
final item = snapshot.data;
if (item == null) return const SizedBox.shrink();
// Bei Server-Titeln die Navidrome-ID, nicht die Abspiel-Adresse:
// mit der fragte die App den Server nach einem Titel namens
// "https://…" und bekam nie einen Text.
final songId = songIdOf(item) ?? navidromeIdOf(item) ?? '';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Songtext',
tooltip: songId.isEmpty
? 'Kein Songtext für diesen Titel verfügbar'
: 'Songtext',
icon: const Icon(Icons.lyrics),
// Bei Server-Titeln die Navidrome-ID, nicht die
// Abspiel-Adresse: mit der fragte die App den Server nach
// einem Titel namens "https://…" und bekam nie einen Text.
onPressed: () => _showLyrics(
context, songIdOf(item) ?? navidromeIdOf(item) ?? ''),
onPressed: songId.isEmpty
? null
: () => _showLyrics(context, songId),
),
// Ein Titel der Bibliothek wird lokal favorisiert, einer
// vom Server am Server — beide bekommen dasselbe Herz,
@@ -75,7 +80,16 @@ class NowPlayingScreen extends StatelessWidget {
),
],
),
body: SafeArea(
body: GestureDetector(
// Wie bei Apple Music/Spotify: den Vollbild-Player von praktisch
// jeder Stelle nach unten wegziehen schließt ihn wieder — dieselbe
// Schwelle wie beim Mini-Player, damit kein zaghaftes Scrollen die
// Ansicht versehentlich zuklappt.
onVerticalDragEnd: (details) {
final geschwindigkeit = details.primaryVelocity ?? 0;
if (geschwindigkeit > wischSchwelle) Navigator.maybePop(context);
},
child: SafeArea(
child: StreamBuilder<MediaItem?>(
stream: handler.mediaItem,
builder: (context, snapshot) {
@@ -134,6 +148,7 @@ class NowPlayingScreen extends StatelessWidget {
);
},
),
),
),
),
);
@@ -450,6 +465,12 @@ class _SleepTimerButton extends StatelessWidget {
? const SizedBox.shrink()
: ListTile(
title: const Text('Timer beenden'),
// Ohne die Restzeit hier war sie faktisch unsichtbar —
// die einzige andere Stelle ist der Tooltip des
// AppBar-Icons, den kaum jemand entdeckt.
subtitle: Text(
'Noch ${remaining.inMinutes}:'
'${(remaining.inSeconds % 60).toString().padLeft(2, '0')}'),
leading: const Icon(Icons.close),
onTap: () {
handler.sleepTimer.cancel();
@@ -583,6 +604,7 @@ class _LyricsSheetState extends State<_LyricsSheet> {
fontSize: 18, fontWeight: FontWeight.w600),
),
IconButton(
tooltip: 'Schließen',
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
+22 -4
View File
@@ -41,6 +41,7 @@ class QueueScreen extends StatelessWidget {
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),
@@ -63,12 +64,15 @@ class QueueScreen extends StatelessWidget {
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.
@@ -113,10 +117,24 @@ class _Zeile extends StatelessWidget {
),
subtitle: Text(item.artist ?? 'Unbekannt',
maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: IconButton(
tooltip: 'Aus Warteschlange entfernen',
icon: const Icon(Icons.close),
onPressed: onEntfernen,
// 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,
);