Author SHA1 Message Date
Hermes (Server) 4fb6ebddda Stand der Animations-Serie sichern (Projekt-Pause)
Punkt 1+2 fertig, Punkt 3 (Karaoke-Glow) im Design abgestimmt und
bereit zur Umsetzung, Punkt 4+5 offen. Für nahtlose Wiederaufnahme
nach der Pause.
2026-08-31 17:13:22 +02:00
Hermes (Server)andClaude Sonnet 5 ce460f9804 CHANGELOG: 3 Bugfixes aus dem finalen Code-Review dokumentiert
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SM6e73V9ZethPiqHSocxUA
2026-08-29 15:57:38 +02:00
Hermes (Server)andClaude Sonnet 5 bc58438262 Fix: RenderFlex-Overflow in NowPlayingScreen während der Öffnen-Geste
Bug (Code-Review, verifiziert): _Angaben (Titel/Künstler/Fortschritt/
Controls) hat eine intrinsische Mindesthöhe, die während der Öffnen-Geste
(progress zwischen ~0.3 und ~1.0, siehe main.dart Positioned.fromRect mit
Rect.lerp Mini-/Vollbild-Player) das noch kleinere, interpolierte
Rechteck übersteigt — Flutter meldete dann "RenderFlex overflowed" bei
praktisch jeder normalen Öffnen-Geste.

Fix: der Inhalt-Slot (in beiden responsiven Zweigen — Row nebeneinander
und Column untereinander) steckt jetzt in
ClipRect(child: ConstrainedBox(maxHeight: raum.maxHeight - Lücke,
child: OverflowBox(maxHeight: infinity, fit: deferToChild, child: _Angaben))).
ConstrainedBox deckelt nur, was OverflowBox nach AUSSEN als eigene Größe
meldet (verhindert, dass der umgebende Row/Column selbst überläuft);
OverflowBox gibt _Angaben davon unabhängig weiterhin unbegrenzte Höhe
(verhindert, dass _Angaben intern überläuft) — überschüssiger Inhalt wird
vom ClipRect während der kurzen, ohnehin noch recht durchsichtigen
Übergangsphase einfach nicht gemalt statt eine Overflow-Warnung
auszulösen. Bei ausreichend Platz (Endzustand progress==1) ist
raum.maxHeight groß genug, dass nichts geklemmt wird — die Optik bleibt
dort unverändert (now_playing_heart_test.dart bestätigt das weiterhin).

Test zuerst (RED: "RenderFlex overflowed by 13 pixels" reproduziert, mit
NowPlayingScreen in einem echten, interpolierten Zwischenrechteck bei
progress≈0.4 auf Basis der HomeShell-Rect-Formel): test/player/
now_playing_screen_overflow_test.dart.

Volle Suite (626 Tests) grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SM6e73V9ZethPiqHSocxUA
2026-08-29 15:55:52 +02:00
Hermes (Server)andClaude Sonnet 5 0fe624d1e8 Fix: PlayerExpansionController oberhalb des Navigators bereitstellen
Bug (Code-Review, P0 — blockierte den Push): PlayerExpansionController
wurde bisher nur INNERHALB von HomeShell.build() bereitgestellt. Per
Navigator.push geöffnete Screens (PlaylistDetailScreen,
ServerTitelScreen) sind im Widget-Baum aber Geschwister von HomeShell im
selben Navigator/Overlay, keine Nachfahren — sie sahen den Provider nicht.
context.read<PlayerExpansionController>().open(context) warf dort zur
Laufzeit ProviderNotFoundException, vom bestehenden try/catch abgefangen
zu einer "Wiedergabe fehlgeschlagen"-Snackbar — der Player öffnete sich
nie.

MeloApp ist jetzt ein StatefulWidget (TickerProviderStateMixin), erzeugt
PlayerExpansionController in initState() und stellt ihn in seiner
MultiProvider-Liste bereit — oberhalb von MaterialApp/Navigator, wie alle
anderen App-weiten Provider. HomeShell erzeugt keinen eigenen mehr,
sondern liest die Referenz per context.read<PlayerExpansionController>()
einmalig (keine Subscription) und reicht sie an seine AnimatedBuilder
weiter, die weiterhin direkt am Controller lauschen — die bewusste
"Consumer nur um PopScope"-Optimierung (Scaffold wird nicht bei jedem
progress-Tick neu gebaut) bleibt dabei erhalten.

Test zuerst (RED mit der heutigen, echten ProviderNotFoundException
reproduziert — via echtem HomeShell + echtem Navigator.push, nicht dem
bisherigen Kurzschluss-Testaufbau, der den Provider manuell direkt neben
das Test-Widget klebt): test/player/provider_scope_navigator_push_test.dart.
Bestehende Tests, die HomeShell direkt aufbauen (hauptmenue_test.dart,
home_shell_test.dart, home_shell_real_expansion_test.dart), an die neue
Verdrahtung angepasst — der Controller kommt jetzt aus ihrer eigenen
MultiProvider-Liste statt aus HomeShell selbst.

Volle Suite (625 Tests) grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SM6e73V9ZethPiqHSocxUA
2026-08-29 15:47:12 +02:00
Hermes (Server)andClaude Sonnet 5 6687f2f67c Fix: PlayerExpansionController.dragEnd rät Öffnen/Schließen nicht mehr aus Vorzeichen
Bug (Code-Review, P0-nah): dragEnd() wählte die Entscheidungslogik allein
über das Vorzeichen von strecke. Bei bereits vollständig offenem Player
klappte ein schwaches, versehentliches Fingerzittern nach oben (kleine
negative Strecke, weit unter der Wisch-Schwelle) den Player ungewollt zu,
weil das Vorzeichen in den ÖFFNEN-Zweig führte statt in den (korrekten)
Schließen-Zweig, der bei so einer schwachen Geste keine Wirkung hätte.

dragEnd() bekommt jetzt den Pflichtparameter `ausVollbild`, der den
Aufrufer-Kontext explizit benennt statt ihn zu erraten. mini_player.dart
(Öffnen-Geste) ruft mit ausVollbild: false, now_playing_screen.dart
(Schließen-Geste) mit ausVollbild: true.

Test zuerst (RED mit der alten 3-Parameter-Signatur reproduziert, dann
GREEN nach dem Fix): test/player/player_expansion_controller_test.dart.
Bestehende Tests (player_expansion_controller_test.dart,
home_shell_expansion_test.dart) an die neue Signatur angepasst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SM6e73V9ZethPiqHSocxUA
2026-08-29 15:36:46 +02:00
Hermes (Server) 1734a656a1 CHANGELOG: Live-Blur-Übergang für den Vollbild-Player 2026-08-29 15:08:22 +02:00
Hermes (Server) 244b5f84c0 Integrationstests: kompletter Zyklus gegen echtes HomeShell + State-Persistenz-Nachweis 2026-08-29 15:05:38 +02:00
Hermes (Server) e2d2a01965 Vollbild-Player: Schließgeste live an progress gekoppelt, symmetrisch zum Öffnen 2026-08-29 14:59:20 +02:00
Hermes (Server) 92148bc97c Mini-Player-Wischgeste steuert PlayerExpansionController statt lokalen Versatz 2026-08-29 14:54:05 +02:00
Hermes (Server) a1eb5831e0 WanderndesCover ersetzt Hero-Übergang: Cover wandert live mit progress 2026-08-29 14:50:40 +02:00
Hermes (Server) 9d0cb90a51 NowPlayingScreen: von Scaffold/Route zu Overlay-Inhalt in HomeShell umgebaut 2026-08-29 14:45:41 +02:00
Hermes (Server) 96113a3e41 HomeShell: Blur-Layer und Mini-Player-Fade an progress gekoppelt 2026-08-29 14:39:56 +02:00
Hermes (Server) aea80d9755 3 Tap-Stellen öffnen den Vollbild-Player jetzt über PlayerExpansionController statt Navigator.push
Zusätzlich test/player/now_playing_heart_test.dart angepasst (Plan-Lücke:
nutzt denselben SongZeile-Tap-Pfad, war nicht in Task 3s Datei-Liste
erwähnt) — Provider ergänzt, Route für die Layout-Prüfung selbst separat
geöffnet, bis Task 5 NowPlayingScreen zu Overlay-Inhalt umbaut.
2026-08-29 14:34:30 +02:00
Hermes (Server) 7a9493955a HomeShell: PlayerExpansionController bereitstellen, Zurück-Taste schließt offenen Player 2026-08-29 14:24:09 +02:00
Hermes (Server) 2baac9ed12 PlayerExpansionController: optischer Öffnen-Fortschritt für den Vollbild-Player 2026-08-29 14:19:29 +02:00
Hermes (Server) dbaec47faf Plan/Spec: Review-Panel-Befunde eingearbeitet
Budget-Adversarial-Panel (3 Reviewer, unabhängig konvergent) fand einen P0
(AnimatedBuilder.child-Fehlgebrauch macht Blur-Layer nicht live) und mehrere
P1s (State-Persistenz-Widerspruch zur Spec, fehlende Hit-Test-/Accessibility-
Isolation, verworfene Loslass-Geschwindigkeit, fehlendes extendBody, Tests
nur gegen Test-Doppelgänger). Alle behoben: eigene AnimatedBuilder pro
reaktiver Ebene (Reaktivitäts-Regel in Global Constraints), Visibility statt
Conditional-Unmount, IgnorePointer/ExcludeSemantics für Hintergrund-Layer,
echte Fling-Geschwindigkeit, extendBody + BottomNavigationBar-Fade, neuer
Integrationstest gegen das echte HomeShell, State-Persistenz-Test.
2026-08-29 14:07:59 +02:00
Hermes (Server) 8f4c3fa56f Implementierungsplan: Vollbild-Player als persistentes Overlay
10 Tasks, TDD-Schritte, Spec-Abdeckung geprüft.
2026-08-29 13:48:49 +02:00
Hermes (Server) 8d496cc70b Spec: Vollbild-Player als persistentes Overlay mit Live-Blur-Übergang
Punkt 2 der HyperOS-Animations-Serie. Bereit zur Freigabe durch Dustin
(via Hermes) vor der Umsetzung.
2026-08-29 13:40:19 +02:00
Hermes (Server) e2304b66cd Gestaffelte Enter-Animation für Songlisten (EinblendItem)
Items in song_list.dart und artist_list.dart faden/rutschen beim ersten
Erscheinen gestaffelt statt abrupt ein — angelehnt an HyperOS' fließende
Motion-Sprache. Läuft einmal pro Element, respektiert Reduce-Motion und
deckelt die Verzögerung ab Index 12.
2026-08-29 12:51:04 +02:00
Hermes (Server) 196303145a Manuelle Baka-Anmeldung übernimmt das Passwort jetzt dauerhaft; Auto-Login-Fehler werden geloggt
Nach erfolgreichem manuellem Login im "Baka-Konto"-Dialog wird das neue
Passwort jetzt zusätzlich in NavidromeService persistiert (dafür ergänzt:
öffentlicher serverUrl-Getter) — vorher schrieb der Dialog es nur ins
BakaAuth-Token, nie zurück in die lokale Navidrome-Ablage, aus der
autoAnmelden() sich beim nächsten App-Start bedient. Ohne diesen Fix kam
der Auto-Login-Fehler nach Ablauf des 7-Tage-Tokens garantiert wieder.

Zusätzlich hält BakaAuth.letzterAutoLoginFehler jetzt den Fehlertext des
letzten Auto-Login-Versuchs fest, statt ihn zu verwerfen — bisher war aus
der App nicht diagnostizierbar, woran ein Auto-Login scheiterte.
2026-08-29 12:38:20 +02:00
Hermes (Server) 609ca37ede Auto-Anmeldung: doppelten Login-Request beim App-Start verhindert (Race durch IndexedStack)
Betraf alle drei Nutzer (Dustin, Baka, Tinker), nicht nur einen: IndexedStack
baut Download-Tab und YT-Suche beim App-Start gleichzeitig, beide loesen
unabhaengig voneinander autoAnmelden() aus. Ohne In-Flight-Absicherung gingen
zwei fast-gleichzeitige Login-Requests an denselben Server, von denen einer
abgelehnt wurde - obwohl die Zugangsdaten korrekt waren.

- BakaAuth.autoAnmelden() haengt einen ueberlappenden Aufruf jetzt an den
  bereits laufenden Login-Versuch an, statt einen zweiten Request zu senden.
- Anmelde-Dialog im Download-Tab zeigt den tatsaechlich bekannten
  Navidrome-Benutzernamen statt immer "Baka" vorauszufuellen.
- Navidrome-Dialog in den Einstellungen waehlt den zuletzt bekannten
  Benutzer vor statt immer "Baka".
2026-08-29 11:31:28 +02:00
Hermes (Server)andClaude Sonnet 5 2cabe36a6c Zuletzt zeigt zuletzt Gehörtes statt neu Hinzugefügtes; Herz sitzt jetzt neben dem Songnamen
- MeloDb.watchRecentlyPlayed(): wertet PlaybackHistory statt dateAddedMs aus,
  ein mehrfach gehörter Song erscheint nur einmal, an der Position seines
  jüngsten Abspielens (Subquery mit GROUP BY songId / MAX(playedAtMs)).
- "Zuletzt" (Karte + RecentlyPlayedScreen, vormals RecentlyAddedScreen) in
  my_music_screen.dart nutzt jetzt watchRecentlyPlayed() statt watchRecent().
- Herz im Vollbild-Player: aus der AppBar-actions-Row entfernt, sitzt jetzt
  direkt neben dem Songtitel in _Angaben (now_playing_screen.dart).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
2026-08-29 11:24:42 +02:00
Hermes (Server) 90b63c467e Zurück-Geste auf Nicht-Start-Tabs navigiert zu Meine Musik statt die App zu minimieren
Tab-Wechsel in HomeShell laufen nur über setState (IndexedStack +
BottomNavigationBar), nie über Navigator.push — eine System-Zurück-Geste
hatte auf Nicht-Start-Tabs also nie eine Route zum Poppen und minimierte
stattdessen die App. build() jetzt in ein PopScope gewickelt, das bei
_index != 0 zuerst zu Tab 0 zurückspringt.
2026-08-27 15:31:21 +02:00
Hermes (Server) 1b096f5227 Doppelten Scan-Button auf Android entfernen (Musik scannen und Erneut scannen taten dasselbe) 2026-08-27 15:18:36 +02:00
Hermes (Server) 94a48675b1 Song-Tap oeffnet den Vollbild-Player mit grossem Cover 2026-08-27 15:10:33 +02:00
Hermes (Server) 648b22bfa7 AudioSession konfigurieren: Audio-Fokus bleibt beim automatischen Songwechsel erhalten 2026-08-27 14:53:59 +02:00
Hermes (Server)andClaude Sonnet 5 7dbea128e0 Shuffle/Wiederholung: PlaybackState meldet echten Zustand statt immer "aus"
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
2026-08-27 14:49:32 +02:00
39 changed files with 4807 additions and 277 deletions
+111
View File
@@ -0,0 +1,111 @@
# Animations-Serie (HyperOS-inspiriert) — Stand bei Pause
Projekt pausiert am 2026-08-31. Dieses Dokument fasst zusammen, wo die
Animations-Serie steht, damit eine neue Session nahtlos weitermachen kann.
## Herkunft
Ausgangspunkt: Analyse-Auftrag zur Xiaomi-Musik-App/HyperOS-Design-Sprache,
daraus ein Bericht mit 5 Animations-Empfehlungen für Mello, priorisiert nach
Aufwand/Wirkung. Punkte 1+2 sind umgesetzt, Punkt 3 ist im Design fertig
abgestimmt, aber **noch nicht implementiert**. Punkte 4+5 sind offen.
## Status je Punkt
### ✅ Punkt 1 — Staggered List-Enter-Animation (fertig, gemerged-bereit)
`EinblendItem`-Widget (`lib/shared/einblend_item.dart`), eingebunden in
`song_list.dart`/`artist_list.dart`. Fertig, getestet, committet+gepusht auf
Branch `fix/tinker-feedback` (im Worktree `~/mello-dev/app`). Noch nicht in
`main` gemerged.
### ✅ Punkt 2 — Vollbild-Player als Live-Blur-Overlay (fertig, gepusht)
NowPlayingScreen von Navigator-Route zu persistentem Overlay in HomeShell
umgebaut (`PlayerExpansionController`, Live-Blur/Cover-Interpolation während
der Wischgeste). Vollständig umgesetzt (10-Task-Plan + 3 Bugfixes aus dem
finalen Review), 626 Tests grün, `flutter analyze` sauber.
- **Branch:** `feature/blur-oeffnen-transition` (Basis: `fix/tinker-feedback`)
- **Worktree:** `~/mello-dev/worktrees/blur-transition` (dieser hier)
- **Gepusht**, PR noch nicht erstellt/gemerged:
https://git.baka-net.de/dustin/Melo/pulls/new/feature/blur-oeffnen-transition
- **Spec:** `docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md`
- **Plan:** `docs/superpowers/plans/2026-08-29-blur-oeffnen-transition.md`
- Durchlief ein Adversarial-Review-Panel (Budget-Mode, 3 Reviewer) vor der
Umsetzung — fand einen echten P0 (falsches `AnimatedBuilder.child`-Muster).
Ein zweites Review NACH der Umsetzung, vor dem Push, fand nochmal 3 echte
Bugs (Provider-Scope-Crash bei per `Navigator.push` geöffneten Screens,
RenderFlex-Overflow während der Öffnen-Geste, asymmetrischer
`dragEnd`-Bug) — alle behoben, siehe Commit-History auf dem Branch.
- **Wichtige Lektion für künftige Overlay-artige Provider:** Ein
`ChangeNotifierProvider`, der nur innerhalb eines Screens (nicht oberhalb
des Navigators) bereitgestellt wird, ist für per `Navigator.push` geöffnete
Screens unsichtbar (die sind Geschwister im selben Navigator/Overlay,
keine Nachfahren). `PlayerExpansionController` sitzt deshalb jetzt in
`MeloApp`s `MultiProvider` (`lib/main.dart`), nicht in `HomeShell`.
### 🔜 Punkt 3 — Karaoke-Glow in der Songtext-Ansicht (Design fertig, NICHT umgesetzt)
**Nächster Schritt beim Wiederaufnehmen.** Brainstorming abgeschlossen,
Design mit Dustin abgestimmt und freigegeben — direkt umsetzbar ohne erneute
Rückfrage:
- **Datei:** `lib/player/now_playing_screen.dart`, Klasse `_Mitlaufend`
(aktuell Zeile ~759-810 auf diesem Branch), nur der `AnimatedDefaultTextStyle`
darin (~Zeile 793-800).
- **Änderung:**
- `style:` bekommt zusätzlich `shadows:`, animiert über den bestehenden
`AnimatedDefaultTextStyle`-Mechanismus (kein neuer Controller nötig,
`TextStyle.lerp` interpoliert `shadows` automatisch):
- Inaktive Zeile: `shadows: const []`
- Aktive Zeile: `shadows: [Shadow(color: MeloTheme.red.withValues(alpha: 0.55), blurRadius: 10)]`
- Textfarbe bleibt wie bisher (`Colors.white`/`MeloTheme.text3`) — bewusst
**kein** rot eingefärbter Text (Kontrast/Lesbarkeit auf dunklem Grund),
stattdessen weißer Text mit rotem Glow dahinter ("Spotlight"-Effekt).
Diese Frage wurde Dustin explizit gestellt, Antwort: Glow statt
Rot-Einfärbung.
- `duration:` von `MeloMotion.fast` (120ms) auf `MeloMotion.normal` (220ms)
korrigiert — laut `theme.dart`s eigener Doku ist `fast` für
Touch-Feedback gedacht, ein Zeilenwechsel ist aber ein Inhalts-Wechsel
(`normal`). Passt auch besser zum "fließenden" Karaoke-Gefühl.
- **Testing:** bestehenden Widget-Test für `_Mitlaufend`/`_LyricsSheet`
(falls vorhanden) auf den neuen `shadows`-Wert prüfen; neuer Test bestätigt
aktive Zeile hat `MeloTheme.red`-farbenen Schatten, inaktive `shadows: []`.
- **Workflow ab hier:** TDD-Umsetzung (bounded, kein Plan-Dokument nötig) →
`@code-reviewer`-Skill (`/code-review`) vor dem Commit → committen+pushen
auf `feature/blur-oeffnen-transition` (baut direkt auf Punkt 2 auf, gleiche
Datei).
### ⬜ Punkt 4 — Advanced-Blur-Kopplung am Farbverlauf-Hintergrund (offen)
Aus dem ursprünglichen Bericht: `_CoverGrund`s Farbverlauf-Hintergrund
zusätzlich leicht auf Wisch-Distanz reagieren lassen (Blur-Radius steigt
beim Öffnen/Schließen). Noch nicht brainstormed/geplant. Mittlerer Aufwand,
baut auf Punkt 2s `progress`-Wert auf (`PlayerExpansionController`).
### ⬜ Punkt 5 — Equalizer-Visualizer (offen, niedrige Priorität)
Spektrum-Visualizer in `equalizer_screen.dart`, analog zu Xiaomis
Musik-App. Braucht Audio-Frequenzanalyse (aktuell nicht vorhanden) —
deutlich höherer Aufwand, im ursprünglichen Bericht selbst als
"nice-to-have, niedrige Priorität" eingestuft. Noch nicht brainstormed.
## Wichtige Rahmenbedingungen fürs Wiederaufnehmen
- **Worktree-Disziplin:** `~/mello-dev/app` ist ein GETEILTER Worktree
(andere Session, aktuell "Hermes"/`mello-dev-80`, arbeitet dort an
YouTube-Gast-Zugang o.ä.) — vor jedem `git`-Befehl dort `git status`
prüfen, nicht blind committen. Für neue, eigenständige Punkte (4/5) einen
eigenen Worktree anlegen (`git worktree add -b <branch> ~/mello-dev/worktrees/<name> <basis>`),
wie bei Punkt 2 geschehen.
- **Flutter-Binary:** IMMER `/home/dustin/development/flutter/bin/flutter`
verwenden, NICHT das über PATH auffindbare Snap-`flutter` — letzteres
wirft "Invalid kernel binary format version" (Toolchain-Mismatch mit den
gecachten Native-Hooks).
- **Pläne/fertige Designs gehen an Hermes** (Peer-Session `mello-dev-80`),
nicht direkt an Dustin im Chat — Hermes bespricht mit Dustin und gibt dann
die Freigabe zurück. Ausnahme: reine Werkzeug-/Ziel-Klärungen.
- **Skill-Workflow laut Projekt-CLAUDE.md:** brainstorming → planning (nur
bei Architectural-Einstufung) → agent-review-panel → TDD → Debugging.
Für Punkt 2 wurde ein Budget-Mode-Adversarial-Panel genutzt (3 Reviewer:
Code-Quality-Auditor, Feasibility-Analyst, Devil's Advocate + Synthese
ohne separaten Richter-Agent, da die Befunde stark konvergent/eindeutig
waren) — hat sich gelohnt, fand echte Bugs vor UND nach der Umsetzung.
- **Subagent-Profile aktiv nutzen:** `@melo-specialist` für alle
App-Änderungen (nicht general-purpose).
+231
View File
@@ -5,6 +5,237 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [Unreleased]
### 🐛 Vollbild-Player Live-Blur-Übergang: 3 Bugfixes aus dem finalen Code-Review (2026-08-29)
Vor dem Push ein Review über den gesamten Diff der Blur-Übergang-Serie
(siehe Eintrag darunter) — 3 echte Bugs gefunden und per TDD behoben:
- 🐛 **Player öffnete sich nicht, wenn er per `Navigator.push` erreicht
wurde** (z. B. Song in einer Playlist oder in Server-Album/Künstler-
Ansicht antippen): `PlayerExpansionController` wurde nur INNERHALB von
`HomeShell.build()` bereitgestellt — ein per `Navigator.push` geöffneter
Screen ist im Baum aber ein Geschwister von `HomeShell`, kein Nachfahre,
und sah den Provider deshalb nicht (`ProviderNotFoundException`,
abgefangen zu einer "Wiedergabe fehlgeschlagen"-Snackbar). Fix: `MeloApp`
stellt den Controller jetzt oberhalb von `MaterialApp`/Navigator bereit,
wie alle anderen App-weiten Provider.
- 🐛 **"RenderFlex overflowed"-Fehler während fast jeder Öffnen-Geste**:
Titel/Künstler/Fortschritt/Controls brauchten mehr Höhe, als das noch
kleine, interpolierte Rechteck zwischen Mini- und Vollbild-Player während
der Übergangsphase hatte. Fix: der Inhalt steckt jetzt in einer
`ClipRect`/`OverflowBox`-Kapselung — er behält seine natürliche Größe,
der überschüssige Teil wird während der kurzen, ohnehin noch
durchsichtigen Übergangsphase einfach nicht gemalt statt eine
Fehlermeldung auszulösen. Der Endzustand bei vollständig geöffnetem
Player sieht unverändert aus.
- 🐛 **Player klappte bei einem schwachen Fingerzittern ungewollt zu**: War
der Player bereits vollständig offen und der Finger verrutschte beim
Versuch, einen Knopf zu treffen, leicht nach oben, wählte die
Wisch-Auswertung fälschlich den ÖFFNEN-Zweig (geraten aus dem Vorzeichen
der Zugstrecke statt aus dem tatsächlichen Aufrufer-Kontext) — der klappte
bei einer so schwachen Geste zu, statt gar nichts zu tun. Fix:
`PlayerExpansionController.dragEnd()` bekommt jetzt den Pflichtparameter
`ausVollbild`, der explizit sagt, ob die Geste am Mini-Player (öffnend)
oder am Vollbild-Player (schließend) passiert.
-**Getestet (RED→GREEN je Fix, eigener Commit):**
`test/player/provider_scope_navigator_push_test.dart` (echtes `HomeShell`
+ echter `Navigator.push`, reproduziert die reale
`ProviderNotFoundException`), `test/player/now_playing_screen_overflow_test.dart`
(reproduziert "RenderFlex overflowed by 13 pixels" in einem echten,
interpolierten Zwischenrechteck), neuer Testfall in
`test/player/player_expansion_controller_test.dart`. Volle Suite
(626 Tests) grün, `flutter analyze` ohne Befund.
### ✨ Vollbild-Player: Live-Blur-Übergang statt fester Animation (2026-08-29)
-**Teil 2 der Animations-Serie**: Übergang Mini-Player ↔ Vollbild-Player
hängt jetzt live an der Zugdistanz der Wischgeste (Blur + Cover-Wanderung),
nicht erst nach Abschluss einer festen Animation. Symmetrisch beim Öffnen
und Schließen.
- 🔧 `NowPlayingScreen` ist keine per `Navigator.push` geöffnete Route mehr,
sondern ein persistentes Overlay in `HomeShell` (`PlayerExpansionController`,
`lib/player/player_expansion_controller.dart`). Der bisherige
`Hero`-Cover-Übergang ist durch `WanderndesCover` ersetzt (Rect-Interpolation
statt Navigator-Flight).
-**Getestet (RED→GREEN):** neue Unit-Tests für `PlayerExpansionController`
(`test/player/player_expansion_controller_test.dart`), Integrationstests
gegen ein Test-Double und das echte `HomeShell`
(`test/player/home_shell_expansion_test.dart`,
`test/player/home_shell_real_expansion_test.dart` — Letzterer beweist
Live-Reaktivität ohne fremden Rebuild-Trigger sowie State-Persistenz von
`_CoverGrundState` über mehrere Öffnen/Schließen-Zyklen), Cover-Interpolation
(`test/player/wanderndes_cover_test.dart`). Volle Suite (623 Tests) grün,
`flutter analyze` ohne Befund.
### ✨ Staggered List-Enter-Animation: Listen faden beim ersten Erscheinen gestaffelt ein (2026-08-29)
-**Teil 1 einer Animations-Serie** (inspiriert von Xiaomis HyperOS-
Design-Sprache — fließende statt abrupte Bewegung): neues Widget
`EinblendItem` (`lib/shared/einblend_item.dart`) blendet ein Listenelement
beim ersten Erscheinen mit Opacity 0→1 und leichtem Versatz (`MeloSpace.sm`
→ 0) ein, Dauer `MeloMotion.normal` (220ms), Kurve `MeloMotion.curve`.
Jedes Element startet um `min(index, 12) × 25ms` versetzt — ab Index 12
ohne zusätzliche Verzögerung, damit lange Listen nicht träge wirken. Läuft
genau einmal je Element-Lebenszeit (nicht bei jedem Rebuild) und
respektiert Reduce-Motion (`MeloMotion.ruhig`): dann erscheint das Element
sofort im Endzustand, ohne Verzögerung oder Bewegung.
- 🔧 Eingebunden in `SongList` (`lib/library/song_list.dart`) und in beide
Listen von `artist_list.dart` (Künstler-Übersicht sowie die Zeilen der
Künstler-Detailseite — Alben, Titel und Zwischenüberschriften). Die
wiederverwendete `SongZeile` selbst bleibt unverändert, damit Suche und
Playlist-Detail (die den Wrapper nicht bekommen sollen) unangetastet
bleiben. `my_music_screen.dart` rendert keine eigene Liste (nur
eingebettete `SortableSongList`/`ArtistListScreen`/`CategoryListScreen`)
— dort war nichts einzubinden.
-**Getestet (RED→GREEN):** neuer Widget-Test
`test/shared/einblend_item_test.dart` prüft Start bei Opacity 0, Ziel bei
1,0 nach `pumpAndSettle()`, sofortigen Endzustand bei
`disableAnimations: true` sowie den Deckel bei Index 12 (Index 50 startet
nicht später als Index 12). Bestehende Tests zu `song_list.dart` liefen
bereits mit `pumpAndSettle()` und blieben unverändert grün. Volle Suite
(604 Tests) grün, `flutter analyze` ohne Befund.
### 🔑 Manuelle Baka-Anmeldung übernahm das neue Passwort nie dauerhaft; Auto-Login-Fehler wurde verworfen (2026-08-29)
- 🐛 **Wer sich nach einer fehlgeschlagenen Auto-Anmeldung manuell im
"Baka-Konto"-Dialog mit dem korrekten (neuen) Passwort anmeldete, bekam
denselben Fehler beim nächsten App-Start (nach Ablauf des 7-Tage-Tokens)
garantiert wieder** — unabhängig vom Server-Zustand. Grund: Navidrome-
und Baka-Konto teilen sich laut Design dieselben Zugangsdaten
(`navidrome_service.dart`), aber der manuelle Anmelde-Dialog
(`_AnmeldeDialogState._anmelden()` in `downloads_screen.dart`) schrieb
das eingegebene Passwort nur ins `BakaAuth`-Token — nie zurück in die
lokale Navidrome-Zugangsdaten-Ablage (`NavidromeService`), aus der
`BakaAuth.autoAnmelden()` sich beim nächsten Start bedient. Der manuelle
Login "half" also nur bis zum nächsten Token-Ablauf.
- 🔧 **Fix:** Nach erfolgreichem manuellem Login persistiert der Dialog die
neuen Zugangsdaten jetzt zusätzlich über
`NavidromeService.speichereZugangsdaten(...)`. Dafür bekam
`NavidromeService` einen fehlenden öffentlichen Getter `serverUrl`.
- 🐛 **`BakaAuth.autoAnmelden()` verwarf den Fehlertext von `anmelden()`
komplett** — aus der App war nicht diagnostizierbar, ob ein Auto-Login
an falschem Passwort, Rate-Limit oder Verbindungsfehler scheiterte.
- 🔧 **Fix:** neues Feld `BakaAuth.letzterAutoLoginFehler` hält den
Fehlertext des letzten Auto-Login-Versuchs fest (zusätzlich per
`debugPrint` geloggt) — die bestehende Race-Condition-Absicherung über
`_laufenderAutoLogin` bleibt unverändert erhalten.
-**Getestet (RED→GREEN):** neuer Widget-Test in `online_screen_test.dart`
bestätigt, dass `nav.passwort`/`nav.benutzer` nach erfolgreichem
manuellem Login den neu eingegebenen Wert zeigen; neuer Unit-Test in
`baka_auth_test.dart` bestätigt, dass `letzterAutoLoginFehler` nach
einem fehlgeschlagenen `autoAnmelden()` den Fehlertext trägt statt
`null` zu bleiben. Volle Suite (601 Tests) grün, `flutter analyze` ohne
Befund.
### 🎧 "Zuletzt" zeigte neu Hinzugefügtes statt zuletzt Gehörtem; Herz saß fern vom Songnamen (2026-08-29)
- 🐛 **Der Schnellzugriff "Zuletzt" auf "Meine Musik" zeigte "zuletzt hinzugefügte" Songs,
nicht "zuletzt gehörte"** — von Tinker gemeldet. Sowohl die Karte als auch der
Vollbild-Screen dahinter nutzten `db.watchRecent()`, das nach `dateAddedMs`
(Hinzufüge-Datum) sortiert, nicht nach dem tatsächlichen Abspielen.
- 🔧 **Fix:** neue Methode `MeloDb.watchRecentlyPlayed()` (`database.dart`) wertet
stattdessen die `PlaybackHistory`-Tabelle aus — eine Subquery ermittelt je Song per
`GROUP BY songId` / `MAX(playedAtMs)` nur den jüngsten Play-Zeitpunkt, damit ein
mehrfach gehörter Song nur einmal erscheint (an der Position seines letzten
Abspielens), zurückverknüpft auf `songs` (ohne getombstonte). "Zuletzt" in
`my_music_screen.dart` (Karte + Vollbild-Screen, `RecentlyAddedScreen` dafür in
`RecentlyPlayedScreen` umbenannt) nutzt jetzt diese Methode.
- 🐛 **Das Favoriten-Herz im Vollbild-Player saß oben in der AppBar**, weit weg vom
Songnamen — ebenfalls von Tinker gemeldet.
- 🔧 **Fix:** `_Herz` aus der AppBar-`actions`-Row entfernt und stattdessen direkt
neben den Titel in `_Angaben` gesetzt (`now_playing_screen.dart`).
-**Getestet:** 3 neue Unit-Tests für `watchRecentlyPlayed()` (RED→GREEN:
Mehrfach-Play erscheint nur einmal an der neuesten Position, mehrere Songs
absteigend sortiert, getombstonter Song mit Historie fehlt) sowie ein neuer
Widget-Test, der bestätigt, dass das Herz nicht mehr in der AppBar, sondern in
derselben Row wie der Songtitel sitzt. `flutter analyze` ohne Befund.
### 🔑 Auto-Anmeldung schlug beim App-Start fehl — betraf ALLE drei Nutzer (2026-08-29)
- 🐛 **Die automatische Baka-Anmeldung klappte beim App-Start oft nicht**
die App fragte dann nach dem "Baka-Konto", obwohl die Zugangsdaten
korrekt waren. Betraf **alle drei Nutzer** (Dustin, Baka, Tinker), nicht
nur einen — von Dustin selbst gemeldet und bei allen drei bestätigt.
Grund: `main.dart` baut die 5 Haupt-Tabs über `IndexedStack` alle sofort
beim ersten Frame, nicht erst beim Umschalten. Der Download-Tab und die
YT-Suche lösen dabei beide unabhängig voneinander denselben Auto-Login
aus (`pruefeServerUser()``BakaAuth.autoAnmelden()`) — praktisch
gleichzeitig, mit denselben Zugangsdaten. `autoAnmelden()` hatte keine
Schutzvorkehrung gegen so einen doppelten Aufruf (der `istAngemeldet`-
Check lief synchron, bevor die erste Anfrage zurückkam), also gingen
zwei fast-gleichzeitige Login-Anfragen an denselben Server raus — der
lehnte vermutlich eine davon ab (Session-/Lock-Konflikt).
- 🔧 **Fix:** `BakaAuth.autoAnmelden()` in `baka_auth.dart` hängt einen
überlappenden Aufruf jetzt an den bereits laufenden Login-Versuch an
(`_laufenderAutoLogin`), statt einen zweiten Request loszuschicken.
- 🔧 **Nebenbei zwei kleine Anmelde-Dialoge korrigiert:** Der Anmelde-
Dialog im Download-Tab (`downloads_screen.dart`) zeigte das
Benutzername-Feld immer mit "Baka" vorausgefüllt, egal wer die App
nutzt — zeigt jetzt den tatsächlich bekannten Navidrome-Benutzernamen
(leer, falls keiner bekannt). Der Navidrome-Dialog in den Einstellungen
(`settings_screen.dart`) wählte unter Baka/Tinker/Dustin immer "Baka"
vor — wählt jetzt den zuletzt bekannten Benutzer vor, falls bekannt.
-**Getestet:** neuer Unit-Test für `autoAnmelden()` (RED→GREEN, zwei
gleichzeitige Aufrufe lösen nur noch einen HTTP-Request aus statt
zwei) sowie ein neuer Widget-Test für die Benutzername-Vorbelegung im
Download-Tab-Dialog (RED→GREEN). Volle Suite grün, `flutter analyze`
ohne Befund.
### ⬅️ Zurück-Geste auf Nicht-Start-Tabs minimierte die App (2026-08-27)
- 🐛 **Auf "Favoriten" (oder einem anderen Tab außer "Meine Musik") tippen und
dann die System-Zurück-Geste nutzen minimierte die App**, statt zur
vorherigen Ansicht zu navigieren. Grund: Tab-Wechsel in `HomeShell`
laufen nur über `setState` (`IndexedStack` + `BottomNavigationBar`), nie
über `Navigator.push` — es lag also nie eine Route auf dem
Navigator-Stack, die eine Zurück-Geste hätte poppen können.
- 🔧 **Fix:** `build()` in `main.dart` in ein `PopScope` gewickelt
(`canPop: _index == 0`), das bei einem Nicht-Start-Tab zuerst zu Tab 0
("Meine Musik") zurückspringt, statt die App zu verlassen.
-**Getestet:** neuer Widget-Test in `home_shell_test.dart` (RED→GREEN,
simuliert die System-Zurück-Geste über `WidgetsApp.didPopRoute()`), volle
Suite (593 Tests) grün, `flutter analyze` ohne Befund.
### 🔄 Doppelten Scan-Button auf Android entfernt (2026-08-27)
- 🐛 **In den Einstellungen gab es auf Android zwei Buttons für dieselbe
Aktion** ("Musik scannen" und "Erneut scannen") — beide riefen dort
denselben geräteweiten MediaStore-Scan auf (`LibraryService.
pickFolderAndScan()` und `.rescan()` sind auf Android identisch, da
Scoped Storage keine Ordnerwahl erlaubt). Nur auf Desktop unterscheiden
sie sich echt (Ordner hinzufügen vs. bekannte Ordner erneut scannen).
- 🔧 **Fix:** Der zweite `ListTile` ("Erneut scannen") wird in
`settings_screen.dart` nur noch angezeigt, wenn `zeigeErneutScannenButton
(Platform.isAndroid)` das zulässt. Die Sichtbarkeits-Entscheidung ist als
pure Funktion in `library_service.dart` ausgelagert (statt direkt
`Platform.isAndroid` in der UI abzufragen), weil sich die echte Plattform
in Flutter-Tests nicht sauber auf "Android" umschalten lässt.
-**Getestet:** 2 neue Unit-Tests für `zeigeErneutScannenButton` (RED→
GREEN), volle Suite (592 Tests) grün, `flutter analyze` ohne Befund.
### 🎧 Song antippen öffnet den Vollbild-Player (2026-08-27)
- 🐛 **Ein Song antippen öffnete früher nur die Wiedergabe, ohne zum
Vollbild-Player mit großem Cover zu wechseln** — man landete weiter auf
der Bibliotheks-/Playlist-/Server-Liste und musste erst den Mini-Player
antippen oder hochwischen, um das große Cover zu sehen. Behoben an den
drei Stellen, an denen ein Song EXPLIZIT zum Abspielen angetippt wird:
Bibliothek (`song_list.dart`), Wiedergabeliste
(`playlist_detail_screen.dart`) und Server-Titel
(`server_titel_screen.dart`, dort auch für die Knöpfe "Abspielen" und
"Zufällig", weil sie über dieselbe zentrale `_spiele`-Methode laufen).
Nutzt dasselbe, im Projekt etablierte Navigationsmuster wie der
Mini-Player (`Navigator.push(MaterialPageRoute(builder: (_) =>
NowPlayingScreen()))`), nach erfolgreicher Wiedergabe und vor einer
eventuellen Offline-Snackbar, damit die Meldung auf dem neuen Screen
sichtbar bleibt.
- ↩️ **Bewusst unverändert:** `queue_screen.dart` (Song innerhalb der
bereits offenen Warteschlange antippen) — dort ist man schon im
Player-Kontext, ein erneutes Aufspringen des Vollbild-Players bei jedem
Sprung wäre aufdringlich.
-**Getestet:** 3 neue Widget-Tests (RED→GREEN, je einer pro Stelle),
volle Suite (590 Tests) grün, `flutter analyze` ohne Befund.
### 📺🔑 YouTube-Tab: Server-User brauchen keine eigene Baka-Anmeldung mehr (2026-08-26) ### 📺🔑 YouTube-Tab: Server-User brauchen keine eigene Baka-Anmeldung mehr (2026-08-26)
- 🔑 **Server-User (Dustin, Baka, Tinker) sehen den Baka-Anmelde-Schritt nicht - 🔑 **Server-User (Dustin, Baka, Tinker) sehen den Baka-Anmelde-Schritt nicht
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,224 @@
# Vollbild-Player als persistentes Overlay mit Live-Blur-Übergang
Status: Approved (Dustin, 2026-08-29) — bereit für Implementierungsplan.
## Kontext
Punkt 2 aus dem Animations-Bericht zu Xiaomis HyperOS-Design-Sprache
(fließende, physikalisch wirkende Bewegung statt starrer, linearer
Animation): Der Übergang vom Mini-Player zum Vollbild-Player
(`NowPlayingScreen`) soll sich beim Öffnen/Schließen per Wischgeste
reaktiv anfühlen — der Hintergrund soll live mit der Zugdistanz des
Fingers verschwimmen (Blur), nicht erst nach Abschluss einer festen
Animation.
Heute (`lib/player/mini_player.dart`, `lib/player/now_playing_screen.dart`,
`lib/player/wischen.dart`) ist die Wischgeste rein **entscheidungsbasiert**:
Der Mini-Player folgt beim Ziehen sichtbar dem Finger (`_versatz` +
`AnimatedContainer`/`Matrix4.translationValues`), aber die eigentliche
Navigation passiert erst nach `onVerticalDragEnd` als normaler
`Navigator.push(MaterialPageRoute(builder: (_) => const NowPlayingScreen()))`.
Während des Ziehens existiert `NowPlayingScreen` im Widget-Baum noch gar
nicht — es gibt keine live-Verbindung zwischen Zugdistanz und einer
Bildschirm-Transition. Dieselbe Route wird an drei weiteren, gestenlosen
Stellen per einfachem Tap gepusht: `lib/library/song_list.dart`,
`lib/playlists/playlist_detail_screen.dart`,
`lib/downloads/server_titel_screen.dart`.
Zwei Architektur-Ansätze wurden im Brainstorming verglichen (interaktive
`PageRoute` mit geteiltem Fortschritts-Notifier vs. persistentes Overlay).
Dustin hat sich für das **persistente Overlay** entschieden (Ansatz 2) —
näher an der Bauweise nativer Musik-Apps (Spotify/Apple Music), auch wenn
der Eingriff größer ist als bei der interaktiven-Route-Variante.
## Ziel
`NowPlayingScreen` wird Teil der dauerhaften App-Hülle (`HomeShell`) statt
einer über den Navigator gepushten Route. Ein geteilter Fortschritts-Wert
(0.0 = eingeklappt/Mini-Player, 1.0 = Vollbild) steuert live:
- die Größe/Position des Vollbild-Inhalts (Interpolation Mini-Player-Rechteck
→ Vollbild)
- die Blur-Intensität des darunterliegenden Tab-Inhalts
- das Ein-/Ausblenden des Mini-Players
Sowohl das Öffnen (Ziehen am Mini-Player, Tap an allen 4 bisherigen
Öffnen-Stellen) als auch das Schließen (Ziehen im Vollbild-Player nach
unten) sollen sich darüber gleich, live und symmetrisch anfühlen.
## Nicht-Ziele
- Keine Änderung an der Wiedergabe-Logik selbst (`playSongs`, `audio_handler.dart`).
- Keine Änderung an internen `Navigator.push`-Aufrufen INNERHALB von
`NowPlayingScreen` (Warteschlange, Songtext-Sheet) — die bleiben echte
Routen/Modals über demselben Navigator.
- Keine Änderung an `wischen.dart`s bestehender Entscheidungslogik
(`oeffnetVollbildMitStrecke`, `titelWechselMitStrecke`,
`wischSchwelle`/`wischStreckeSchwelle`) — die wird unverändert
wiederverwendet, nur um einen rein optischen `progress`-Wert ergänzt.
- Keine Änderung an Punkt 1 (`EinblendItem`, bereits umgesetzt/gemergt).
- Punkte 35 des Animations-Berichts (Karaoke-Highlight, Advanced-Blur-
Kopplung am Farbverlauf, Equalizer-Visualizer) sind nicht Teil dieses
Specs.
## Entscheidungen (aus dem Brainstorming, mit Dustin abgestimmt)
| Frage | Entscheidung |
|---|---|
| Architektur-Ansatz | Persistentes Overlay in `HomeShell` statt interaktiver `PageRoute` |
| Tap-Only-Stellen (song_list.dart, playlist_detail_screen.dart, server_titel_screen.dart) | Bekommen denselben Übergang (automatischer `progress` 0→1), nicht nur die Mini-Player-Geste |
| Schließgeste im Vollbild-Player | Wird symmetrisch umgebaut — live am Finger, nicht nur Entscheidung bei Loslassen |
| Cover-Übergang (bisher `Hero`) | Wird von Hand nachgebaut (Rect-Interpolation über `progress`), da `Hero` nur bei echten Navigator-Transitions feuert |
| Android-Zurück-Taste | `PopScope` in `HomeShell`: bei `progress > 0` schließt Zurück den Player statt die Route/App zu verlassen — notwendiger Teil des Designs, keine Alternative |
## Architektur
### Neue Komponente: `PlayerExpansionController`
Datei: `lib/player/player_expansion_controller.dart`. `ChangeNotifier`,
lebt in `HomeShell`s `State` (die `TickerProviderStateMixin` bekommt, um
als `vsync` für einen internen `AnimationController` zu dienen), bereit-
gestellt per `ChangeNotifierProvider.value` oberhalb von `IndexedStack`,
`MiniPlayer` und `NowPlayingScreen`.
Zustand:
- `double progress` — 0.0…1.0, rein optisch, öffentlich lesbar.
Methoden:
- `dragBy(double dy)` — während des Ziehens aufgerufen (Mini-Player beim
Öffnen, Vollbild-Player beim Schließen, jeweils mit passendem Vorzeichen).
Bildet `progress = (aufsummierte Zugstrecke / referenzHoehe).clamp(0, 1)`.
`referenzHoehe` ist ein fester Wert (300px) — bewusst **entkoppelt** von
der Entscheidungs-Schwelle in `wischen.dart`; er bestimmt nur, wie "weit"
sich der optische Übergang bei einer bestimmten Zugstrecke anfühlt.
- `dragEnd(double geschwindigkeit, double strecke)` — ruft unverändert
`oeffnetVollbildMitStrecke`/die Schließ-Variante auf, um zu entscheiden,
ob `progress` zu 1.0 oder 0.0 animiert wird (`AnimationController.fling`
mit der Loslass-Geschwindigkeit als Startimpuls, danach `MeloMotion.curve`
bis zum jeweiligen Ziel).
- `open()` — animiert `progress` 0→1 über `MeloMotion.ruhig(context,
MeloMotion.normal)` (Tap-Auslöser, kein Ziehen beteiligt).
- `close()` — spiegelbildlich 1→0 (z.B. Zurück-Taste, `X`-artiger Schließen-
Button falls später gewünscht).
- Reduce-Motion: alle Übergänge über `MeloMotion.ruhig` — bei deaktivierten
Animationen springt `progress` sofort auf das Ziel.
### Widget-Baum in `HomeShell`
Heute: `Scaffold` mit `body`-Bereich, der `IndexedStack` (5 Tabs) + darunter
`MiniPlayer` + `BottomNavigationBar` anordnet. Neu: der `body`-Bereich wird
ein `Stack`, von unten nach oben:
1. `IndexedStack` (Tabs) + `BottomNavigationBar` — unverändert
2. `AnimatedBuilder` auf den Controller: nur wenn `progress > 0`, ein
`BackdropFilter(filter: ImageFilter.blur(sigmaX: progress * maxSigma,
sigmaY: progress * maxSigma))` über Punkt 1 — Widget wird bei
`progress == 0` komplett aus dem Baum entfernt (kein Performance-Overhead
im Ruhezustand). `maxSigma = 20.0` als Startwert (spürbarer Weichzeichner,
ohne die Tab-Umrisse völlig zu verlieren) — im Review/auf echtem Gerät
nachjustierbar, siehe „Offene Risiken“.
3. `MiniPlayer` — `Opacity(opacity: 1 - progress)`, bei `progress == 1`
ebenfalls aus dem Baum entfernt (`IgnorePointer`/Kein Hit-Testing mehr,
sonst blockiert eine unsichtbare Leiste Gesten im Vollbild-Player)
4. `NowPlayingScreen`-Inhalt — Größe/Position per `Rect.lerp` zwischen dem
Mini-Player-Rechteck (volle Breite, `MiniPlayer.hoehe` + Fortschrittsbalken
hoch, am unteren Rand über der `BottomNavigationBar`) und dem
Vollbild-Rechteck; Detail-Inhalt (Titel, Steuerung, Songtext-Icon etc.)
blendet erst ab `progress > 0.3` ein (Crossfade), darunter ist nur das
wandernde Cover sichtbar — vermeidet, dass die volle Player-UI in eine
72px hohe Box gequetscht wird
### Cover-Übergang ohne `Hero`
`_Cover`/`Hero(tag: coverHeldenName, ...)` entfällt an beiden Stellen.
Stattdessen: eine neue `_WanderndesCover`-Komponente, die ihr Rechteck
(Größe + Position) direkt aus `progress` berechnet (`Rect.lerp` zwischen
Mini- und Vollbild-Cover-Rect) und denselben `CoverImage`-Widget-Typ mit
interpolierendem `radius` (6 → 16) rendert. Lebt als Teil der `NowPlayingScreen`-
Inhalts-Ebene (Punkt 4 oben) — der `MiniPlayer` selbst zeigt kein eigenes
Cover mehr, sobald `progress > 0`, um Doppel-Rendering zu vermeiden.
### Migration der 4 Öffnen-Stellen
- `lib/library/song_list.dart` (`SongZeile._zeile.onTap`),
`lib/playlists/playlist_detail_screen.dart`,
`lib/downloads/server_titel_screen.dart`: `Navigator.push(MaterialPageRoute(
builder: (_) => const NowPlayingScreen()))` wird durch
`context.read<PlayerExpansionController>().open()` ersetzt — nur im
Erfolgspfad nach `playSongs(...)`, Fehlerpfad/Snackbar unverändert. Der
`NowPlayingScreen`-Import entfällt an diesen 3 Stellen vollständig.
- `lib/player/mini_player.dart`: `_oeffne()` ruft `.open()` statt zu pushen;
`onVerticalDragUpdate`/`onVerticalDragEnd` rufen `.dragBy()`/`.dragEnd()`
statt lokal `_versatz` zu verwalten (die bisherige rein visuelle
Mitzieh-Logik der Leiste geht im gemeinsamen `progress`-Wert auf).
### Zurück-Taste (Android)
`HomeShell` bekommt ein `PopScope(canPop: controller.progress == 0,
onPopInvokedWithResult: (did, _) { if (!did) controller.close(); })` (oder
Äquivalent je nach Flutter-Version im Projekt) um den `Scaffold`/`Stack`.
Bei offenem oder halb gezogenem Player schließt Zurück den Player; erst bei
`progress == 0` verhält sich Zurück wie heute (App verlassen/vorherige
Route).
## Verhaltensänderung (positiv, aber erwähnenswert)
`_CoverGrundState` (Farbverlauf-Hintergrund aus dem Cover) läuft heute bei
jedem Öffnen neu, weil jede Route ein frischer State ist. Als dauerhaftes
Overlay bleibt der State über mehrere Öffnen/Schließen-Zyklen hinweg
erhalten — die Cover-Farbe ist beim erneuten Öffnen sofort da, kein
wiederholtes Bild-Dekodieren pro Öffnen. Reine Verbesserung, aber ein
beobachtbarer Unterschied gegenüber heute.
## Testing
- **Unit** (`test/player/player_expansion_controller_test.dart`):
`dragBy`/`dragEnd`-Übergänge, Clamping auf [0,1], Reduce-Motion-Sofortsprung,
`open()`/`close()`-Zielwerte. Entscheidungslogik selbst bleibt in
`wischen.dart` und wird dort NICHT erneut getestet (unverändert).
- **Widget** (`test/player/home_shell_expansion_test.dart` o.ä.):
- Ziehen am Mini-Player erhöht `progress`, Blur-Layer erscheint ab
`progress > 0`
- Tap aus `song_list.dart` (bzw. Stellvertreter-Widget im Test) ruft
`.open()` auf, kein `Navigator.push` mehr
- Zurück-Taste bei `progress > 0` schließt den Player statt zu poppen/die
App zu verlassen
- Ziehen nach unten im Vollbild-Player reduziert `progress` symmetrisch
bis zum Schließen
- **Cover-Interpolation**: Test, dass `_WanderndesCover` bei
`progress = 0 / 0.5 / 1` die erwarteten Rect/Radius-Werte liefert
(`Rect.lerp`-Ergebnis direkt prüfbar, kein Golden-Test nötig)
## Offene Risiken
- `BackdropFilter` ist in Flutter performance-sensibel (GPU-Kosten pro
Frame) — bewusst nur im Baum, wenn `progress > 0`, um Idle-Kosten zu
vermeiden; bei sehr low-end Geräten ggf. später Sigma-Obergrenze
reduzieren, falls sich das Ziehen ruckelig anfühlt (kein Blocker für die
erste Umsetzung, aber im Review beobachten).
- `PlayerExpansionController` muss den `mounted`-Zustand von `HomeShell`
respektieren (kein `notifyListeners()`/Controller-Zugriff nach `dispose()`)
— analog zum bestehenden Muster in `_CoverGrundState`.
## Nachtrag: Review-Panel (2026-08-29, vor Umsetzung)
Ein Budget-Adversarial-Panel (3 Reviewer + Synthese) hat den Implementierungsplan
gegen diese Spec geprüft und dabei Lücken gefunden, die der Plan direkt behebt
(Details im Implementierungsplan-Dokument, nicht hier wiederholt):
- Hit-Test- und Screenreader-Isolation zum Hintergrund waren in der ersten
Planfassung nicht vorgesehen — jetzt Teil von Task 4 (`IgnorePointer`/
`ExcludeSemantics` auf Tab-Inhalt und `BottomNavigationBar` bei `progress > 0`).
- Der Wegfall der bisher über `Scaffold`/`AppBar` impliziten Status-Icon-
Helligkeit war nicht bedacht — jetzt Teil von Task 5 (`AnnotatedRegion`).
- Ohne `extendBody: true` hätte das "Vollbild"-Rechteck nicht zur tatsächlichen
`Scaffold.body`-Höhe gepasst — jetzt Teil von Task 4.
- Erwogene, aber verworfene Alternative: ein nur *während der Geste*
eingeblendeter `OverlayEntry` (Blur + Cover-Vorschau) direkt in
`mini_player.dart`, der bei Erreichen der Öffnen-Schwelle in einen normalen
`Navigator.push` mit unverändertem, Hero-basiertem `NowPlayingScreen`
übergeht — deutlich weniger Code, behält Route-Semantik (Accessibility-
Grenze, einfacheres Zurück-Verhalten) vollständig. Nicht gewählt, weil
Dustin sich für die Overlay-Variante (näher an Spotify/Apple Music, siehe
oben) entschieden hat — hier dokumentiert, damit die Abwägung nachvollziehbar
bleibt, falls der Wartungsaufwand des Overlay-Ansatzes sich später als zu
hoch erweist.
+16 -3
View File
@@ -679,9 +679,10 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
} }
Future<void> _anmelden() async { Future<void> _anmelden() async {
final nav = context.read<DownloadService>().navidrome;
await showDialog( await showDialog(
context: context, context: context,
builder: (_) => const _AnmeldeDialog(), builder: (_) => _AnmeldeDialog(benutzer: nav.benutzer),
); );
} }
@@ -869,14 +870,18 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
/// Fragt Benutzername und Passwort ab und gibt beides zurück. /// Fragt Benutzername und Passwort ab und gibt beides zurück.
class _AnmeldeDialog extends StatefulWidget { class _AnmeldeDialog extends StatefulWidget {
const _AnmeldeDialog(); const _AnmeldeDialog({this.benutzer = ''});
/// Bereits bekannter Navidrome-Benutzername (leer, falls keiner bekannt) —
/// vorausgefüllt statt immer "Baka" zu zeigen, egal wer die App nutzt.
final String benutzer;
@override @override
State<_AnmeldeDialog> createState() => _AnmeldeDialogState(); State<_AnmeldeDialog> createState() => _AnmeldeDialogState();
} }
class _AnmeldeDialogState extends State<_AnmeldeDialog> { class _AnmeldeDialogState extends State<_AnmeldeDialog> {
final _benutzer = TextEditingController(text: 'Baka'); late final _benutzer = TextEditingController(text: widget.benutzer);
final _passwort = TextEditingController(); final _passwort = TextEditingController();
bool _sichtbar = false; bool _sichtbar = false;
bool _verbindet = false; bool _verbindet = false;
@@ -898,6 +903,14 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
final fehler = await auth.anmelden(_benutzer.text.trim(), _passwort.text); final fehler = await auth.anmelden(_benutzer.text.trim(), _passwort.text);
if (!mounted) return; if (!mounted) return;
if (fehler == null) { if (fehler == null) {
// Navidrome- und Baka-Konto teilen sich die Zugangsdaten (siehe
// navidrome_service.dart) — ohne diese Übernahme würde der nächste
// Auto-Login nach Ablauf des Baka-Tokens wieder mit dem alten
// Passwort scheitern.
final nav = context.read<DownloadService>().navidrome;
await nav.speichereZugangsdaten(
nav.serverUrl, _benutzer.text.trim(), _passwort.text);
if (!mounted) return;
Navigator.pop(context); Navigator.pop(context);
return; return;
} }
+3
View File
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../library/database.dart'; import '../library/database.dart';
import '../library/song_media.dart'; import '../library/song_media.dart';
import '../player/audio_handler.dart'; import '../player/audio_handler.dart';
import '../player/player_expansion_controller.dart';
import '../services/navidrome_service.dart'; import '../services/navidrome_service.dart';
import '../shared/cover.dart'; import '../shared/cover.dart';
import '../shared/lauf_balken.dart'; import '../shared/lauf_balken.dart';
@@ -77,6 +78,8 @@ class _ServerTitelScreenState extends State<ServerTitelScreen> {
]; ];
try { try {
final uebersprungen = await handler.loadPlaylist(items, startIndex: ab); final uebersprungen = await handler.loadPlaylist(items, startIndex: ab);
if (!mounted) return;
context.read<PlayerExpansionController>().open(context);
if (uebersprungen > 0) { if (uebersprungen > 0) {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))), SnackBar(content: Text(offlineHinweis(uebersprungen))),
+12 -2
View File
@@ -5,6 +5,7 @@ import '../settings/app_settings.dart';
import '../shared/auswahl.dart'; import '../shared/auswahl.dart';
import '../shared/auswahl_leiste.dart'; import '../shared/auswahl_leiste.dart';
import '../shared/cover.dart'; import '../shared/cover.dart';
import '../shared/einblend_item.dart';
import '../shared/theme.dart'; import '../shared/theme.dart';
import '../shared/titel_listen_screen.dart'; import '../shared/titel_listen_screen.dart';
import 'category_service.dart'; import 'category_service.dart';
@@ -44,7 +45,10 @@ class _ArtistListScreenState extends State<ArtistListScreen> {
itemCount: artists.length, itemCount: artists.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final artist = artists[i]; final artist = artists[i];
return _KuenstlerZeile(name: artist, songs: grouped[artist]!); return EinblendItem(
index: i,
child: _KuenstlerZeile(name: artist, songs: grouped[artist]!),
);
}, },
); );
}, },
@@ -167,13 +171,16 @@ class _KuenstlerScreenState extends State<KuenstlerScreen> {
body: ListView.builder( body: ListView.builder(
itemCount: vorspann + widget.songs.length, itemCount: vorspann + widget.songs.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
Widget zeile() {
if (zeigeAlben) { if (zeigeAlben) {
if (i == 0) return const _Kopf('Alben'); if (i == 0) return const _Kopf('Alben');
if (i <= albumListe.length) { if (i <= albumListe.length) {
final eintrag = albumListe[i - 1]; final eintrag = albumListe[i - 1];
return _AlbumZeile(name: eintrag.key, songs: eintrag.value); return _AlbumZeile(name: eintrag.key, songs: eintrag.value);
} }
if (i == albumListe.length + 1) return const Divider(height: 1); if (i == albumListe.length + 1) {
return const Divider(height: 1);
}
if (i == albumListe.length + 2) return const _Kopf('Alle Titel'); if (i == albumListe.length + 2) return const _Kopf('Alle Titel');
} }
final index = i - vorspann; final index = i - vorspann;
@@ -186,6 +193,9 @@ class _KuenstlerScreenState extends State<KuenstlerScreen> {
ausgewaehlt: _auswahl.contains(song.id), ausgewaehlt: _auswahl.contains(song.id),
onAuswahlWechsel: () => _wechsleAuswahl(song.id), onAuswahlWechsel: () => _wechsleAuswahl(song.id),
); );
}
return EinblendItem(index: i, child: zeile());
}, },
), ),
), ),
+29
View File
@@ -212,6 +212,35 @@ class MeloDb extends _$MeloDb {
.watch(); .watch();
} }
/// Überwacht die zuletzt GEHÖRTEN Songs (nicht getombstonte), aus
/// [PlaybackHistory]. Ein mehrfach gespielter Song erscheint nur einmal —
/// an der Position seines jüngsten Abspielens. Dafür wird pro [songId]
/// zuerst der jüngste `playedAtMs`-Eintrag ermittelt (Subquery mit
/// `GROUP BY`), und darüber zurück auf [songs] verknüpft.
Stream<List<Song>> watchRecentlyPlayed({int limit = 50}) {
final letzterPlay = playbackHistory.playedAtMs.max();
final letztePlaysProSong = selectOnly(playbackHistory)
..addColumns([playbackHistory.songId, letzterPlay])
..groupBy([playbackHistory.songId]);
final subquery = Subquery(letztePlaysProSong, 'letzte_plays');
final letzterPlayRef = subquery.ref(letzterPlay);
final query = select(songs).join([
innerJoin(
subquery,
subquery.ref(playbackHistory.songId).equalsExp(songs.id),
useColumns: false,
),
])
..where(songs.deleted.equals(false))
..orderBy([
OrderingTerm(expression: letzterPlayRef, mode: OrderingMode.desc)
])
..limit(limit);
return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList());
}
/// Alle Songs inkl. getombstonte. Intern für ID-Stabilität beim Scan. /// Alle Songs inkl. getombstonte. Intern für ID-Stabilität beim Scan.
Future<List<Song>> allSongs() => select(songs).get(); Future<List<Song>> allSongs() => select(songs).get();
+7
View File
@@ -13,6 +13,13 @@ import 'scan_service.dart';
String get addMusicLabel => String get addMusicLabel =>
Platform.isAndroid ? 'Musik scannen' : 'Musikordner hinzufügen'; Platform.isAndroid ? 'Musik scannen' : 'Musikordner hinzufügen';
/// Ob der "Erneut scannen"-Button zusätzlich zum [addMusicLabel]-Button
/// angezeigt wird. Auf Android tun [LibraryService.pickFolderAndScan] und
/// [LibraryService.rescan] dasselbe (kompletter MediaStore-Scan) — dort
/// reicht ein Button. Pure Funktion statt direkter [Platform.isAndroid]-
/// Abfrage, damit sie unabhängig von der echten Plattform testbar ist.
bool zeigeErneutScannenButton(bool isAndroid) => !isAndroid;
/// Koordiniert Ordnerwahl und Scans; hält den Scan-Fortschritt für die UI. /// Koordiniert Ordnerwahl und Scans; hält den Scan-Fortschritt für die UI.
/// Android scannt automatisch über MediaStore (Scoped Storage lässt keinen /// Android scannt automatisch über MediaStore (Scoped Storage lässt keinen
/// direkten Dateizugriff zu); Desktop scannt vom Nutzer gewählte Ordner. /// direkten Dateizugriff zu); Desktop scannt vom Nutzer gewählte Ordner.
+9 -9
View File
@@ -234,17 +234,17 @@ class _QuickAccessRow extends StatelessWidget {
), ),
if (sektionen.contains(AppSettings.sektionZuletzt)) if (sektionen.contains(AppSettings.sektionZuletzt))
StreamBuilder<List<Song>>( StreamBuilder<List<Song>>(
stream: db.watchRecent(limit: 50), stream: db.watchRecentlyPlayed(limit: 50),
builder: (context, snapshot) { builder: (context, snapshot) {
final recent = snapshot.data ?? const <Song>[]; final recent = snapshot.data ?? const <Song>[];
return _QuickCard( return _QuickCard(
icon: Icons.schedule, icon: Icons.schedule,
label: 'Zuletzt', label: 'Zuletzt',
subtitle: 'Neu hinzugefügt', subtitle: 'Zuletzt gehört',
coverPath: recent.isEmpty ? null : recent.first.coverPath, coverPath: recent.isEmpty ? null : recent.first.coverPath,
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => const RecentlyAddedScreen()), builder: (_) => const RecentlyPlayedScreen()),
), ),
); );
}, },
@@ -363,22 +363,22 @@ class _Empty extends StatelessWidget {
} }
} }
/// Vollbild-Liste der zuletzt hinzugefügten Songs (Schnellzugriff "Zuletzt"). /// Vollbild-Liste der zuletzt gehörten Songs (Schnellzugriff "Zuletzt").
class RecentlyAddedScreen extends StatelessWidget { class RecentlyPlayedScreen extends StatelessWidget {
const RecentlyAddedScreen({super.key}); const RecentlyPlayedScreen({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final db = context.read<MeloDb>(); final db = context.read<MeloDb>();
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Zuletzt hinzugefügt')), appBar: AppBar(title: const Text('Zuletzt gehört')),
body: StreamBuilder<List<Song>>( body: StreamBuilder<List<Song>>(
stream: db.watchRecent(limit: 100), stream: db.watchRecentlyPlayed(limit: 100),
builder: (context, snapshot) { builder: (context, snapshot) {
final songs = snapshot.data ?? const <Song>[]; final songs = snapshot.data ?? const <Song>[];
if (songs.isEmpty) { if (songs.isEmpty) {
return const Center( return const Center(
child: Text('Noch nichts hinzugefügt', child: Text('Noch nichts abgespielt',
style: TextStyle(color: MeloTheme.text2)), style: TextStyle(color: MeloTheme.text2)),
); );
} }
+8 -1
View File
@@ -3,9 +3,11 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../player/audio_handler.dart'; import '../player/audio_handler.dart';
import '../player/player_expansion_controller.dart';
import '../playlists/create_playlist_dialog.dart'; import '../playlists/create_playlist_dialog.dart';
import '../settings/app_settings.dart'; import '../settings/app_settings.dart';
import '../shared/cover.dart'; import '../shared/cover.dart';
import '../shared/einblend_item.dart';
import '../shared/favorite_button.dart'; import '../shared/favorite_button.dart';
import '../shared/lauf_balken.dart'; import '../shared/lauf_balken.dart';
import 'category_service.dart'; import 'category_service.dart';
@@ -93,7 +95,9 @@ class SongList extends StatelessWidget {
controller: controller, controller: controller,
itemExtent: festeHoehe ? songZeilenHoeheFuer(context) : null, itemExtent: festeHoehe ? songZeilenHoeheFuer(context) : null,
itemCount: songs.length, itemCount: songs.length,
itemBuilder: (context, i) => SongZeile( itemBuilder: (context, i) => EinblendItem(
index: i,
child: SongZeile(
song: songs[i], song: songs[i],
warteschlange: songs, warteschlange: songs,
index: i, index: i,
@@ -103,6 +107,7 @@ class SongList extends StatelessWidget {
? null ? null
: () => onAuswahlWechsel!(songs[i].id), : () => onAuswahlWechsel!(songs[i].id),
), ),
),
); );
} }
} }
@@ -378,6 +383,8 @@ class SongZeile extends StatelessWidget {
coverOf: (s) => categories.coverFor(s, coverOf: (s) => categories.coverFor(s,
groupByCategory: settings.groupCoversByCategory), groupByCategory: settings.groupCoversByCategory),
); );
if (!context.mounted) return;
context.read<PlayerExpansionController>().open(context);
if (uebersprungen > 0) { if (uebersprungen > 0) {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))), SnackBar(content: Text(offlineHinweis(uebersprungen))),
+193 -5
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:ui';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -19,6 +20,8 @@ import 'library/search_screen.dart';
import 'player/audio_effects.dart'; import 'player/audio_effects.dart';
import 'player/audio_handler.dart'; import 'player/audio_handler.dart';
import 'player/mini_player.dart'; import 'player/mini_player.dart';
import 'player/now_playing_screen.dart';
import 'player/player_expansion_controller.dart';
import 'services/baka_auth.dart'; import 'services/baka_auth.dart';
import 'services/logger_service.dart'; import 'services/logger_service.dart';
import 'services/download_service.dart'; import 'services/download_service.dart';
@@ -112,9 +115,35 @@ Future<void> main() async {
runApp(const MeloApp()); runApp(const MeloApp());
} }
class MeloApp extends StatelessWidget { class MeloApp extends StatefulWidget {
const MeloApp({super.key}); const MeloApp({super.key});
@override
State<MeloApp> createState() => _MeloAppState();
}
class _MeloAppState extends State<MeloApp> with TickerProviderStateMixin {
// Muss oberhalb des Navigators bereitgestellt werden (Review-Fund): auch
// per Navigator.push geöffnete Screens (z.B. PlaylistDetailScreen,
// ServerTitelScreen) rufen .open(context) auf. Als HomeShell-internen
// Provider steckte der Controller nur innerhalb von HomeShell.build() — für
// eine gepushte Route (ein Geschwister von HomeShell im selben
// Navigator/Overlay, kein Nachfahre) war er dort unsichtbar und
// context.read<PlayerExpansionController>() warf ProviderNotFoundException.
late final PlayerExpansionController _expansion;
@override
void initState() {
super.initState();
_expansion = PlayerExpansionController(vsync: this);
}
@override
void dispose() {
_expansion.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiProvider( return MultiProvider(
@@ -132,6 +161,8 @@ class MeloApp extends StatelessWidget {
ChangeNotifierProvider<YtSearchService>.value(value: _ytSearch), ChangeNotifierProvider<YtSearchService>.value(value: _ytSearch),
ChangeNotifierProvider<SyncService>.value(value: _sync), ChangeNotifierProvider<SyncService>.value(value: _sync),
ChangeNotifierProvider<DownloadService>.value(value: _downloads), ChangeNotifierProvider<DownloadService>.value(value: _downloads),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: _expansion),
], ],
child: MaterialApp( child: MaterialApp(
title: 'Melo', title: 'Melo',
@@ -222,16 +253,136 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
const FavoritesScreen(), const FavoritesScreen(),
const YoutubeSearchScreen(), const YoutubeSearchScreen(),
]; ];
return Scaffold( // Nur die Referenz holen, NICHT abonnieren (kein context.watch): die
body: Column( // Scaffold-weiten AnimatedBuilder unten hören direkt am Controller
// (animation: expansion) und aktualisieren sich selbst live. Ein
// context.watch hier würde stattdessen bei JEDEM _expansion-Tick den
// kompletten HomeShell.build() (inkl. Scaffold-Rumpf) neu aufrufen.
final expansion = context.read<PlayerExpansionController>();
// Consumer NUR um PopScope — sein `child:` (der Scaffold-Rumpf) wird
// laut Flutter-Semantik bei jedem expansion-Tick NICHT neu gebaut, das
// ist hier auch nicht nötig: PopScope selbst braucht nur einen aktuellen
// Wert bei jedem HomeShell-Rebuild (z.B. Zurück-Taste gedrückt), keine
// Frame-genaue Aktualisierung während des Ziehens. Der Blur-Layer/Mini-
// Player-Fade in Task 4 braucht dagegen echte Live-Reaktivität und
// bekommt dafür einen eigenen, davon unabhängigen AnimatedBuilder direkt
// an seiner Stelle im Stack — siehe Reaktivitäts-Regel in Global
// Constraints.
return Consumer<PlayerExpansionController>(
builder: (context, expansion, child) => PopScope(
// Offener/halb gezogener Player hat Vorrang vor dem Tab-Reset:
// Zurück soll ihn zuerst schließen, nicht gleichzeitig auch noch
// den Tab wechseln.
canPop: expansion.progress == 0 && _index == 0,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (expansion.progress > 0) {
expansion.close(context);
} else {
setState(() => _index = 0);
}
},
child: child!,
),
child: Scaffold(
extendBody: true, // NowPlayingScreen (Task 5) braucht die volle Höhe bis unter die BottomNavigationBar
body: Stack(
children: [
AnimatedBuilder(
animation: expansion,
builder: (context, child) => IgnorePointer(
ignoring: expansion.progress > 0,
child: ExcludeSemantics(
excluding: expansion.progress > 0,
child: child,
),
),
child: Column(
children: [ children: [
Expanded(child: IndexedStack(index: _index, children: tabs)), Expanded(child: IndexedStack(index: _index, children: tabs)),
const MiniPlayer(), const SizedBox(height: MiniPlayer.hoehe), // Platz für den Overlay-Mini-Player
],
),
),
AnimatedBuilder(
animation: expansion,
builder: (context, child) {
final p = expansion.progress;
if (p == 0) return const SizedBox.shrink();
return Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: p * 20.0, sigmaY: p * 20.0),
child: Container(color: Colors.transparent),
),
);
},
),
AnimatedBuilder(
animation: expansion,
builder: (context, child) => Positioned(
left: 0,
right: 0,
bottom: 0,
child: Opacity(
opacity: (1 - expansion.progress).clamp(0.0, 1.0),
child: IgnorePointer(
ignoring: expansion.progress > 0,
child: child,
),
),
),
child: const MiniPlayer(),
),
AnimatedBuilder(
animation: expansion,
builder: (context, child) {
final p = expansion.progress;
return Positioned.fromRect(
rect: Rect.lerp(_miniRect(context), _vollbildRect(context), p)!,
child: Visibility(
visible: p > 0,
maintainState: true, // _CoverGrundState bleibt erhalten, siehe Spec
maintainAnimation: true,
child: Opacity(
// Detail-Inhalt blendet erst ab p > 0.3 ein, siehe Spec.
opacity: ((p - 0.3) / 0.7).clamp(0.0, 1.0),
child: child,
),
),
);
},
child: const NowPlayingScreen(),
),
StreamBuilder<MediaItem?>(
stream: context.read<MeloAudioHandler>().mediaItem,
builder: (context, snapshot) {
final item = snapshot.data;
if (item == null) return const SizedBox.shrink();
return AnimatedBuilder(
animation: expansion,
builder: (context, child) => WanderndesCover(
progress: expansion.progress,
artUri: item.artUri,
miniRect: _miniCoverRect(context),
vollbildRect: _vollbildCoverRect(context),
),
);
},
),
], ],
), ),
// Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund // Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
// optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü. // optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü.
bottomNavigationBar: DecoratedBox( bottomNavigationBar: AnimatedBuilder(
animation: expansion,
builder: (context, child) => Opacity(
opacity: (1 - expansion.progress).clamp(0.0, 1.0),
child: IgnorePointer(
ignoring: expansion.progress > 0,
child: child,
),
),
child: DecoratedBox(
decoration: const BoxDecoration( decoration: const BoxDecoration(
border: Border(top: BorderSide(color: MeloTheme.border)), border: Border(top: BorderSide(color: MeloTheme.border)),
), ),
@@ -257,6 +408,43 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
], ],
), ),
), ),
),
),
); );
} }
Rect _miniRect(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final unten = size.height - MiniPlayer.hoehe - kBottomNavigationBarHeight;
return Rect.fromLTWH(0, unten, size.width, MiniPlayer.hoehe);
}
Rect _vollbildRect(BuildContext context) => Offset.zero & MediaQuery.sizeOf(context);
/// Feste 52×52-Größe, linksbündig mit MeloSpace.md-Abstand, vertikal
/// mittig in MiniPlayer.hoehe — Werte aus mini_player.dart übernommen
/// (dortiges Cover entfällt, siehe Task 6 Step 5).
Rect _miniCoverRect(BuildContext context) {
final mini = _miniRect(context);
const seite = 52.0;
final top = mini.top + (MiniPlayer.hoehe - seite) / 2;
return Rect.fromLTWH(mini.left + MeloSpace.md, top, seite, seite);
}
/// Nicht per Formel bestimmbar (Review-Panel-Fund): `_CoverPlatzhalter`
/// sitzt in NowPlayingScreens eigenem responsivem LayoutBuilder — die
/// tatsächliche Position wird stattdessen gemessen, siehe
/// coverPlatzhalterKey.
Rect _vollbildCoverRect(BuildContext context) {
final box = coverPlatzhalterKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) {
// Vor dem ersten Layout (z.B. ganz am Anfang, progress noch 0) —
// Notlösung: Platzhalter mittig im Vollbild-Rechteck annehmen, bis
// der erste Frame mit progress > 0 eine echte Messung liefert.
final voll = _vollbildRect(context);
final seite = voll.width - 48;
return Rect.fromLTWH(voll.left + 24, voll.top + 80, seite, seite);
}
return box.localToGlobal(Offset.zero) & box.size;
}
} }
+19
View File
@@ -130,6 +130,22 @@ int startIndexNachFilter(List<bool> behalten, int startIndex) {
return davor.clamp(0, gesamt - 1); return davor.clamp(0, gesamt - 1);
} }
/// Übersetzung des Player-Shuffle-Zustands in den `audio_service`-Wert für
/// `PlaybackState.shuffleMode`. Als reine Funktion ausgelagert, damit sie
/// ohne laufenden `AudioPlayer` testbar ist — `_transformEvent()` läuft bei
/// jedem Player-Event und lässt sich nicht isoliert aufrufen.
AudioServiceShuffleMode toAudioServiceShuffleMode(bool enabled) =>
enabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none;
/// Übersetzung des Player-Loop-Modus in den `audio_service`-Wert für
/// `PlaybackState.repeatMode`. Siehe [toAudioServiceShuffleMode].
AudioServiceRepeatMode toAudioServiceRepeatMode(LoopMode loopMode) =>
switch (loopMode) {
LoopMode.one => AudioServiceRepeatMode.one,
LoopMode.all => AudioServiceRepeatMode.all,
LoopMode.off => AudioServiceRepeatMode.none,
};
/// Im Offline-Modus ist keiner der gewählten Titel ohne Netz abspielbar. /// Im Offline-Modus ist keiner der gewählten Titel ohne Netz abspielbar.
class NichtsOfflineVerfuegbar implements Exception { class NichtsOfflineVerfuegbar implements Exception {
const NichtsOfflineVerfuegbar(); const NichtsOfflineVerfuegbar();
@@ -251,6 +267,7 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
/// das in den Einstellungen erlaubt hat. /// das in den Einstellungen erlaubt hat.
Future<void> _watchHeadphones() async { Future<void> _watchHeadphones() async {
final session = await AudioSession.instance; final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
session.devicesChangedEventStream.listen((event) { session.devicesChangedEventStream.listen((event) {
final added = event.devicesAdded.map((d) => d.type).toSet(); final added = event.devicesAdded.map((d) => d.type).toSet();
if (shouldAutoPlay( if (shouldAutoPlay(
@@ -633,6 +650,8 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
}[_player.processingState] ?? }[_player.processingState] ??
AudioProcessingState.idle, AudioProcessingState.idle,
playing: _player.playing, playing: _player.playing,
shuffleMode: toAudioServiceShuffleMode(_player.shuffleModeEnabled),
repeatMode: toAudioServiceRepeatMode(_player.loopMode),
updatePosition: _player.position, updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition, bufferedPosition: _player.bufferedPosition,
speed: _player.speed, speed: _player.speed,
+36 -47
View File
@@ -2,10 +2,9 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../shared/cover.dart';
import '../shared/theme.dart'; import '../shared/theme.dart';
import 'audio_handler.dart'; import 'audio_handler.dart';
import 'now_playing_screen.dart'; import 'player_expansion_controller.dart';
import 'wischen.dart'; import 'wischen.dart';
/// Kompakte Wiedergabe-Leiste über der Bottom-Nav. /// Kompakte Wiedergabe-Leiste über der Bottom-Nav.
@@ -53,23 +52,20 @@ class _Leiste extends StatefulWidget {
} }
class _LeisteState extends State<_Leiste> { class _LeisteState extends State<_Leiste> {
/// Wie weit der Finger die Leiste gerade zieht — nur zur Anzeige, die /// Für die horizontale Titel-Wechsel-Geste (unverändert).
/// eigentliche Entscheidung fällt erst bei [onVerticalDragEnd]/ double _horizontalVersatz = 0;
/// [onHorizontalDragEnd]. `dy` bleibt <= 0: nach unten ist unbelegt, ein bool _ziehtHorizontalGerade = false;
/// Mitführen dorthin würde Wirkung vortäuschen, die es nicht gibt.
Offset _versatz = Offset.zero;
bool _ziehtGerade = false;
void _oeffne(BuildContext context) => Navigator.of(context).push( /// Rohe, aufsummierte vertikale Zugstrecke seit Gestenbeginn — exakt wie
MaterialPageRoute(builder: (_) => const NowPlayingScreen()), /// bisher `_versatz.dy`, nur ohne die Optik-Verantwortung (die liegt jetzt
); /// im PlayerExpansionController). Wird für [PlayerExpansionController.
/// dragEnd] gebraucht, das dieselbe rohe Strecke wie wischen.dart erwartet;
/// aus `progress` ließe sich das wegen des Clampings nicht mehr exakt
/// zurückrechnen.
double _vertikaleStrecke = 0;
void _zurueckschnappen() { void _oeffne(BuildContext context) =>
setState(() { context.read<PlayerExpansionController>().open(context);
_ziehtGerade = false;
_versatz = Offset.zero;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -88,30 +84,27 @@ class _LeisteState extends State<_Leiste> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
GestureDetector( GestureDetector(
// Wischen wie in jeder anderen Musik-App: nach oben öffnet die onVerticalDragStart: (_) => _vertikaleStrecke = 0,
// Vollbild-Wiedergabe, seitwärts wechselt den Titel. Die Leiste onVerticalDragUpdate: (details) {
// folgt dabei sichtbar dem Finger — sonst blieb ein _vertikaleStrecke += details.delta.dy;
// bedächtiges, aber weites Wischen (unter der Schwung-Schwelle) context.read<PlayerExpansionController>().dragBy(details.delta.dy);
// ganz ohne Reaktion.
onVerticalDragUpdate: (details) => setState(() {
_ziehtGerade = true;
final dy = _versatz.dy + details.delta.dy;
_versatz = Offset(_versatz.dx, dy > 0 ? 0 : dy);
}),
onVerticalDragEnd: (details) {
final oeffnet = oeffnetVollbildMitStrecke(
_versatz.dy, details.velocity.pixelsPerSecond.dy);
_zurueckschnappen();
if (oeffnet) _oeffne(context);
}, },
onVerticalDragEnd: (details) => context
.read<PlayerExpansionController>()
.dragEnd(context, _vertikaleStrecke,
details.velocity.pixelsPerSecond.dy,
ausVollbild: false),
onHorizontalDragUpdate: (details) => setState(() { onHorizontalDragUpdate: (details) => setState(() {
_ziehtGerade = true; _ziehtHorizontalGerade = true;
_versatz = Offset(_versatz.dx + details.delta.dx, _versatz.dy); _horizontalVersatz += details.delta.dx;
}), }),
onHorizontalDragEnd: (details) { onHorizontalDragEnd: (details) {
final wechsel = titelWechselMitStrecke( final wechsel = titelWechselMitStrecke(
_versatz.dx, details.velocity.pixelsPerSecond.dx); _horizontalVersatz, details.velocity.pixelsPerSecond.dx);
_zurueckschnappen(); setState(() {
_ziehtHorizontalGerade = false;
_horizontalVersatz = 0;
});
switch (wechsel) { switch (wechsel) {
case Wechsel.naechster: case Wechsel.naechster:
handler.skipToNext(); handler.skipToNext();
@@ -124,24 +117,20 @@ class _LeisteState extends State<_Leiste> {
child: InkWell( child: InkWell(
onTap: () => _oeffne(context), onTap: () => _oeffne(context),
child: AnimatedContainer( child: AnimatedContainer(
// Während des Ziehens sofort folgen (keine Verzögerung); duration: _ziehtHorizontalGerade
// beim Loslassen ohne Wirkung sichtbar zurückschnappen.
duration: _ziehtGerade
? Duration.zero ? Duration.zero
: MeloMotion.ruhig(context, MeloMotion.normal), : MeloMotion.ruhig(context, MeloMotion.normal),
curve: MeloMotion.curve, curve: MeloMotion.curve,
transform: transform: Matrix4.translationValues(_horizontalVersatz, 0, 0),
Matrix4.translationValues(_versatz.dx, _versatz.dy, 0),
height: MiniPlayer.hoehe, height: MiniPlayer.hoehe,
child: Row( child: Row(
children: [ children: [
const SizedBox(width: MeloSpace.md), const SizedBox(width: MeloSpace.md),
// Dasselbe Bild wie im Vollbild: es wandert beim Öffnen // Das Cover rendert WanderndesCover in HomeShell in
// dorthin, statt dass ein neuer Bildschirm aufspringt. // einer eigenen Ebene über allem (deckt exakt diese
Hero( // Position bei progress == 0 ab) — hier nur Platz
tag: coverHeldenName, // freihalten, damit Titel/Bedienung nicht springen.
child: CoverImage(artUri: item.artUri, size: 52), const SizedBox(width: 52),
),
const SizedBox(width: MeloSpace.md), const SizedBox(width: MeloSpace.md),
Expanded( Expanded(
child: Column( child: Column(
+226 -120
View File
@@ -1,7 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui' show lerpDouble;
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show OverflowBoxFit;
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../library/database.dart'; import '../library/database.dart';
@@ -13,10 +16,11 @@ import '../shared/theme.dart';
import 'audio_handler.dart'; import 'audio_handler.dart';
import 'cover_farbe.dart'; import 'cover_farbe.dart';
import 'lrc.dart'; import 'lrc.dart';
import 'player_expansion_controller.dart';
import 'queue_screen.dart'; import 'queue_screen.dart';
import 'wischen.dart';
/// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls. /// Inhalt des Vollbild-Players — wird von HomeShell in ein per progress
/// interpoliertes Rechteck gesetzt, ist selbst kein Scaffold/keine Route.
class NowPlayingScreen extends StatelessWidget { class NowPlayingScreen extends StatelessWidget {
const NowPlayingScreen({super.key}); const NowPlayingScreen({super.key});
@@ -31,41 +35,225 @@ class NowPlayingScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>(); final handler = context.read<MeloAudioHandler>();
return _CoverGrund( // Ersetzt, was bisher implizit über Scaffold/AppBar(backgroundColor:
// transparent) auf dem durchgehend dunklen Theme lief (siehe
// MeloTheme.dark/CLAUDE.md „Dark Theme“) — ohne Scaffold/AppBar muss die
// Status-Icon-Helligkeit hier explizit gesetzt werden, sonst bleiben die
// Icons je nach Systemzustand zufällig dunkel auf dunklem Grund.
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: _CoverGrund(
handler: handler, handler: handler,
child: Scaffold( child: Material(
// Der Grund kommt aus dem Coverbild — deshalb hier durchsichtig. color: Colors.transparent,
backgroundColor: Colors.transparent, child: SafeArea(
extendBodyBehindAppBar: true, child: Column(
appBar: AppBar( children: [
backgroundColor: Colors.transparent, _VollbildLeiste(handler: handler, onLyrics: _showLyrics),
actions: [ Expanded(
child: _SchliessGeste(
child: StreamBuilder<MediaItem?>(
stream: handler.mediaItem,
builder: (context, snapshot) {
final item = snapshot.data;
if (item == null) {
return const Center(child: Text('Nichts in Wiedergabe'));
}
return LayoutBuilder(
builder: (context, raum) {
final nebeneinander = raum.maxWidth > raum.maxHeight;
// Während der Öffnen-Geste ist das interpolierte
// Rechteck (main.dart, Rect.lerp Mini-/Vollbild-
// Player) oft noch kleiner, als _Angaben an
// Mindesthöhe braucht — ohne diese Kapselung meldet
// Flutter dann "RenderFlex overflowed" (im Row-Zweig
// unten bekäme _Angaben selbst eine zu knappe Höhe
// zugeteilt, im Column-Zweig überschreitet Lücke +
// _Angaben-Eigenhöhe zusammen die verfügbare Höhe).
// ConstrainedBox AUSSEN um OverflowBox deckelt, was
// OverflowBox nach außen als eigene Größe meldet,
// hart auf das von raum bekannte Maß; OverflowBox
// selbst gibt ihrem Kind (_Angaben) über
// maxHeight: infinity davon UNABHÄNGIG weiterhin
// unbegrenzte Höhe — _Angaben behält so seine
// natürliche Größe, der überschüssige Teil wird
// vom ClipRect einfach nicht gemalt statt eine
// Overflow-Warnung auszulösen. Läge die Deckelung
// stattdessen INNEN (zwischen OverflowBox und
// _Angaben), bekäme _Angaben selbst zu wenig Höhe
// und liefe intern über. Bei ausreichend Platz
// (Endzustand progress==1) ist raum.maxHeight groß
// genug, dass nichts geklemmt wird — die Optik
// bleibt dort unverändert.
final inhalt = ClipRect(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: (raum.maxHeight - MeloSpace.lg)
.clamp(0.0, double.infinity),
),
child: OverflowBox(
alignment: Alignment.center,
maxHeight: double.infinity,
fit: OverflowBoxFit.deferToChild,
child: _Angaben(item: item, handler: handler),
),
),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: nebeneinander
? Row(children: [
Expanded(child: Center(child: _CoverPlatzhalter())),
const SizedBox(width: MeloSpace.lg),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: inhalt,
),
),
),
])
: Column(children: [
Expanded(child: Center(child: _CoverPlatzhalter())),
const SizedBox(height: MeloSpace.lg),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: inhalt,
),
),
]),
);
},
);
},
),
),
),
],
),
),
),
),
);
}
}
/// Schlüssel des Cover-Platzhalters — Task 6 misst darüber die tatsächliche
/// Vollbild-Position/-Größe des Covers (`RenderBox.localToGlobal`), statt
/// `NowPlayingScreen`s responsives Layout (Row/Column-Weiche, AspectRatio)
/// von außen nachzurechnen.
final coverPlatzhalterKey = GlobalKey();
/// Platzhalter im Layout — das eigentliche Cover rendert `WanderndesCover`
/// (Task 6) in einer eigenen Ebene über allem, damit es unabhängig vom
/// restlichen Inhalt zwischen Mini- und Vollbild-Rechteck wandern kann.
class _CoverPlatzhalter extends StatelessWidget {
const _CoverPlatzhalter();
@override
Widget build(BuildContext context) =>
AspectRatio(key: coverPlatzhalterKey, aspectRatio: 1, child: const SizedBox());
}
/// Cover-Bild, das zwischen Mini-Player- und Vollbild-Rechteck wandert.
///
/// Ersetzt die frühere `Hero`-Animation: die feuert nur bei echten
/// Navigator-Transitions, und `NowPlayingScreen` ist seit dem Overlay-Umbau
/// keine Route mehr. Positionierung/Größe kommen direkt aus [progress],
/// live an die Zugstrecke der Wischgeste gekoppelt.
class WanderndesCover extends StatelessWidget {
const WanderndesCover({
super.key,
required this.progress,
required this.artUri,
required this.miniRect,
required this.vollbildRect,
});
final double progress;
final Uri? artUri;
final Rect miniRect;
final Rect vollbildRect;
static const _miniRadius = 6.0;
static const _vollbildRadius = 16.0;
@override
Widget build(BuildContext context) {
final rect = Rect.lerp(miniRect, vollbildRect, progress)!;
final radius = lerpDouble(_miniRadius, _vollbildRadius, progress)!;
return Positioned.fromRect(
rect: rect,
child: CoverImage(artUri: artUri, radius: radius),
);
}
}
/// Schließt den Vollbild-Player per Wischgeste nach unten — symmetrisch zum
/// Öffnen am Mini-Player, live über denselben PlayerExpansionController.
/// Eigenes StatefulWidget, weil eine lokale Rohstrecke über die Gestendauer
/// mitgeführt werden muss (analog zum bisherigen `_versatz`-Muster in
/// mini_player.dart).
class _SchliessGeste extends StatefulWidget {
const _SchliessGeste({required this.child});
final Widget child;
@override
State<_SchliessGeste> createState() => _SchliessGesteState();
}
class _SchliessGesteState extends State<_SchliessGeste> {
double _strecke = 0;
@override
Widget build(BuildContext context) {
final controller = context.read<PlayerExpansionController>();
return GestureDetector(
// Ohne opaque behavior fängt die Geste keine Berührungen über den
// großflächigen, unbemalten Bereichen des Inhalts ab (z.B. über dem
// Cover-Platzhalter, der nur ein leeres SizedBox ist) — GestureDetector
// reagiert per Default (deferToChild) nur dort, wo ein Nachfahre
// tatsächlich etwas zeichnet (per Testlauf verifiziert).
behavior: HitTestBehavior.opaque,
onVerticalDragStart: (_) => _strecke = 0,
onVerticalDragUpdate: (details) {
_strecke += details.delta.dy;
controller.dragBy(details.delta.dy);
},
onVerticalDragEnd: (details) => controller.dragEnd(
context, _strecke, details.velocity.pixelsPerSecond.dy,
ausVollbild: true),
child: widget.child,
);
}
}
/// Die bisherigen AppBar-Actions (Songtext, Sleep-Timer, Warteschlange) als
/// eigene Kopfzeile statt echter AppBar — eine Route/Scaffold gibt es hier
/// nicht mehr.
class _VollbildLeiste extends StatelessWidget {
const _VollbildLeiste({required this.handler, required this.onLyrics});
final MeloAudioHandler handler;
final void Function(BuildContext, String) onLyrics;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
StreamBuilder<MediaItem?>( StreamBuilder<MediaItem?>(
stream: handler.mediaItem, stream: handler.mediaItem,
builder: (context, snapshot) { builder: (context, snapshot) {
final item = snapshot.data; final item = snapshot.data;
if (item == null) return const SizedBox.shrink(); 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) ?? ''; final songId = songIdOf(item) ?? navidromeIdOf(item) ?? '';
return Row( return IconButton(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: songId.isEmpty tooltip: songId.isEmpty
? 'Kein Songtext für diesen Titel verfügbar' ? 'Kein Songtext für diesen Titel verfügbar'
: 'Songtext', : 'Songtext',
icon: const Icon(Icons.lyrics), icon: const Icon(Icons.lyrics),
onPressed: songId.isEmpty onPressed: songId.isEmpty ? null : () => onLyrics(context, songId),
? null
: () => _showLyrics(context, songId),
),
// Ein Titel der Bibliothek wird lokal favorisiert, einer
// vom Server am Server — beide bekommen dasselbe Herz,
// statt dass eines davon ausgegraut bleibt.
_Herz(item: item),
],
); );
}, },
), ),
@@ -79,78 +267,6 @@ class NowPlayingScreen extends StatelessWidget {
), ),
), ),
], ],
),
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) {
final item = snapshot.data;
if (item == null) {
return const Center(child: Text('Nichts in Wiedergabe'));
}
return LayoutBuilder(
builder: (context, raum) {
// Im Querformat ist die Höhe knapp: Cover und Bedienung
// untereinander ließen vom Cover einen Streifen übrig oder
// schoben die Knöpfe aus dem Bild. Nebeneinander bleibt beides
// benutzbar. Dieselbe Aufteilung hilft auf einem Tablet.
final nebeneinander = raum.maxWidth > raum.maxHeight;
final inhalt = _Angaben(item: item, handler: handler);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: nebeneinander
? Row(
children: [
Expanded(
child: Center(child: _Cover(item: item)),
),
const SizedBox(width: MeloSpace.lg),
// Der Inhalt bekommt eine feste Obergrenze: auf
// einem breiten Bildschirm zerrte er sonst über
// die ganze Breite und wäre schlecht lesbar.
Expanded(
child: Center(
child: ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: 420),
child: inhalt,
),
),
),
],
)
: Column(
children: [
Expanded(
child: Center(child: _Cover(item: item)),
),
const SizedBox(height: MeloSpace.lg),
Center(
child: ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: 520),
child: inhalt,
),
),
],
),
);
},
);
},
),
),
),
),
); );
} }
} }
@@ -240,13 +356,24 @@ class _Angaben extends StatelessWidget {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text(
item.title, item.title,
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
),
// Ein Titel der Bibliothek wird lokal favorisiert, einer
// vom Server am Server — beide bekommen dasselbe Herz,
// statt dass eines davon ausgegraut bleibt.
_Herz(item: item),
],
),
const SizedBox(height: MeloSpace.sm), const SizedBox(height: MeloSpace.sm),
Text( Text(
item.artist ?? 'Unbekannt', item.artist ?? 'Unbekannt',
@@ -287,27 +414,6 @@ class _Herz extends StatelessWidget {
} }
} }
/// Gemeinsamer Name des Coverbilds in Mini-Player und Vollbild — daran
/// erkennt Flutter, dass es dasselbe Bild ist, und lässt es zwischen beiden
/// wandern statt einen neuen Bildschirm aufspringen zu lassen.
const coverHeldenName = 'melo-cover';
class _Cover extends StatelessWidget {
const _Cover({required this.item});
final MediaItem item;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1,
child: Hero(
tag: coverHeldenName,
child: CoverImage(artUri: item.artUri, radius: 16),
),
);
}
}
class _ProgressBar extends StatelessWidget { class _ProgressBar extends StatelessWidget {
const _ProgressBar({required this.handler}); const _ProgressBar({required this.handler});
final MeloAudioHandler handler; final MeloAudioHandler handler;
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import '../shared/theme.dart';
import 'wischen.dart';
/// Wie viel Zugstrecke (Pixel) einem vollen Öffnen-Fortschritt entspricht —
/// rein darstellerisch, unabhängig von der Öffnen/Schließen-Entscheidung in
/// [wischen.dart].
const oeffnenReferenzHoehe = 300.0;
/// Optischer Fortschritt zwischen Mini-Player (0) und Vollbild-Player (1).
///
/// Trennt bewusst zwei Dinge: WANN geöffnet/geschlossen wird (unverändert
/// [oeffnetVollbildMitStrecke] aus wischen.dart) und WIE WEIT der Übergang
/// gerade optisch ist (dieser Wert, live an die Zugstrecke gekoppelt).
class PlayerExpansionController extends ChangeNotifier {
PlayerExpansionController({required TickerProvider vsync})
: _animation = AnimationController(vsync: vsync) {
_animation.addListener(notifyListeners);
}
final AnimationController _animation;
double get progress => _animation.value;
/// [dy] ist details.delta.dy einer laufenden Wischgeste, unverändertes
/// Vorzeichen. Nach oben (negativ) erhöht progress, nach unten (positiv)
/// verringert ihn — dieselbe Formel bedient Öffnen- und Schließen-Geste
/// symmetrisch, je nachdem, wo progress gerade steht. Bewusst rekursiv auf
/// dem bereits geclampten Wert (nicht auf der aufsummierten Rohstrecke):
/// so reagiert eine Richtungsumkehr mitten in der Geste sofort, auch wenn
/// die Geste vorher schon über [oeffnenReferenzHoehe] hinausgezogen wurde.
void dragBy(double dy) {
_animation.value = (_animation.value - dy / oeffnenReferenzHoehe).clamp(0.0, 1.0);
}
/// [strecke]/[geschwindigkeit] wie in wischen.dart (negativ = nach oben).
/// [ausVollbild] benennt den Aufrufer-Kontext explizit, statt ihn aus dem
/// Vorzeichen von [strecke] zu raten (früherer Bug: bei bereits offenem
/// Player klappte ein schwaches, versehentliches Aufwärtszittern — negative
/// Strecke, weit unter der Schwelle — den Player zu, weil das Vorzeichen
/// allein in den ÖFFNEN-Zweig führte, der bei so einer schwachen Geste
/// `false` liefert). `false` (Mini-Player, Öffnen-Geste) entscheidet direkt
/// über [oeffnetVollbildMitStrecke]; `true` (Vollbild-Player, Schließen-
/// Geste) über dieselbe Funktion mit gespiegeltem Vorzeichen — "stark genug
/// nach unten" ist spiegelbildlich zu "stark genug nach oben". Fliegt
/// danach mit der echten Loslass-Geschwindigkeit als Startimpuls zum
/// entschiedenen Ziel.
void dragEnd(BuildContext context, double strecke, double geschwindigkeit,
{required bool ausVollbild}) {
final zielIstOffen = ausVollbild
? !oeffnetVollbildMitStrecke(-strecke, -geschwindigkeit)
: oeffnetVollbildMitStrecke(strecke, geschwindigkeit);
if (MediaQuery.disableAnimationsOf(context)) {
_animation.value = zielIstOffen ? 1.0 : 0.0;
return;
}
// -geschwindigkeit/oeffnenReferenzHoehe, weil dragBy() ein negatives dy
// (nach oben) in steigenden progress übersetzt — dieselbe Umrechnung
// rückwärts. Zeigt die tatsächliche Geschwindigkeit nicht zum
// entschiedenen Ziel (z.B. ein Aufwärtswisch, der am Ende minimal
// zurückfedert), erzwingt ein moderater Mindestimpuls die Zielrichtung,
// statt fling() mit widersprüchlichem Vorzeichen zu verwirren.
final rohimpuls = -geschwindigkeit / oeffnenReferenzHoehe;
final zeigtZumZiel = zielIstOffen ? rohimpuls > 0 : rohimpuls < 0;
final impuls = zeigtZumZiel ? rohimpuls : (zielIstOffen ? 1.0 : -1.0);
_animation.fling(velocity: impuls);
}
void open(BuildContext context) => _animiereZu(context, 1.0);
void close(BuildContext context) => _animiereZu(context, 0.0);
/// Für Tap-Auslöser (kein Ziehen, keine Loslass-Geschwindigkeit) — anders
/// als [dragEnd] mit fester Kurve/Dauer aus [MeloMotion], nicht als Fling.
void _animiereZu(BuildContext context, double ziel) {
if (MediaQuery.disableAnimationsOf(context)) {
_animation.value = ziel;
return;
}
_animation.animateTo(ziel, duration: MeloMotion.normal, curve: MeloMotion.curve);
}
@override
void dispose() {
_animation.dispose();
super.dispose();
}
}
@@ -7,6 +7,7 @@ import '../library/database.dart';
import '../library/playlist_service.dart'; import '../library/playlist_service.dart';
import '../library/song_media.dart'; import '../library/song_media.dart';
import '../player/audio_handler.dart'; import '../player/audio_handler.dart';
import '../player/player_expansion_controller.dart';
import '../settings/app_settings.dart'; import '../settings/app_settings.dart';
import '../shared/cover.dart'; import '../shared/cover.dart';
import '../shared/lauf_balken.dart'; import '../shared/lauf_balken.dart';
@@ -133,6 +134,8 @@ class _Zeile extends StatelessWidget {
coverOf: (s) => categories.coverFor(s, coverOf: (s) => categories.coverFor(s,
groupByCategory: settings.groupCoversByCategory), groupByCategory: settings.groupCoversByCategory),
); );
if (!context.mounted) return;
context.read<PlayerExpansionController>().open(context);
if (uebersprungen > 0) { if (uebersprungen > 0) {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))), SnackBar(content: Text(offlineHinweis(uebersprungen))),
+21 -1
View File
@@ -48,6 +48,16 @@ class BakaAuth extends ChangeNotifier {
String? _token; String? _token;
String _benutzer = ''; String _benutzer = '';
/// Laufender Login-Versuch aus [autoAnmelden] — solange er offen ist,
/// hängt sich ein überlappender Aufruf daran statt einen zweiten
/// Request loszuschicken (Race Condition: mehrere Tabs im IndexedStack
/// lösen beim App-Start gleichzeitig autoAnmelden aus).
Future<void>? _laufenderAutoLogin;
/// Fehlertext des letzten automatischen Anmeldeversuchs — für Diagnose,
/// da autoAnmelden() (anders als anmelden()) keinen Rückgabewert hat.
String? letzterAutoLoginFehler;
bool get istAngemeldet => _token != null && _token!.isNotEmpty; bool get istAngemeldet => _token != null && _token!.isNotEmpty;
String get benutzer => _benutzer; String get benutzer => _benutzer;
@@ -108,7 +118,17 @@ class BakaAuth extends ChangeNotifier {
/// authentifizierten Aufruf ohnehin automatisch. /// authentifizierten Aufruf ohnehin automatisch.
Future<void> autoAnmelden(String benutzer, String passwort) async { Future<void> autoAnmelden(String benutzer, String passwort) async {
if (istAngemeldet || benutzer.isEmpty || passwort.isEmpty) return; if (istAngemeldet || benutzer.isEmpty || passwort.isEmpty) return;
await anmelden(benutzer, passwort); _laufenderAutoLogin ??= _autoAnmeldenIntern(benutzer, passwort)
.whenComplete(() => _laufenderAutoLogin = null);
await _laufenderAutoLogin;
}
Future<void> _autoAnmeldenIntern(String benutzer, String passwort) async {
final fehler = await anmelden(benutzer, passwort);
letzterAutoLoginFehler = fehler;
if (fehler != null) {
debugPrint('Auto-Anmeldung fehlgeschlagen: $fehler');
}
} }
Future<void> abmelden() async { Future<void> abmelden() async {
+1
View File
@@ -145,6 +145,7 @@ class NavidromeService {
/// Zugangsdaten. /// Zugangsdaten.
String get benutzer => _user; String get benutzer => _user;
String get passwort => _password; String get passwort => _password;
String get serverUrl => _serverUrl;
/// Dekodiert eine Subsonic-JSON-Antwort und wirft [NavidromeException], /// Dekodiert eine Subsonic-JSON-Antwort und wirft [NavidromeException],
/// wenn der Server `status:"failed"` meldet (z. B. falsches Passwort) — /// wenn der Server `status:"failed"` meldet (z. B. falsches Passwort) —
+9 -1
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -103,6 +105,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
enabled: !lib.scanning, enabled: !lib.scanning,
onTap: lib.pickFolderAndScan, onTap: lib.pickFolderAndScan,
), ),
if (zeigeErneutScannenButton(Platform.isAndroid))
ListTile( ListTile(
leading: const Icon(Icons.refresh), leading: const Icon(Icons.refresh),
title: const Text('Erneut scannen'), title: const Text('Erneut scannen'),
@@ -343,7 +346,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
void _zeigeNavidromeDialog() { void _zeigeNavidromeDialog() {
final urlCtrl = TextEditingController(text: 'https://musik.baka-net.de'); final urlCtrl = TextEditingController(text: 'https://musik.baka-net.de');
final passCtrl = TextEditingController(); final passCtrl = TextEditingController();
String selectedUser = 'Baka'; // Vorauswahl: der zuletzt bekannte Navidrome-Benutzer, falls er einer
// der drei Server-User ist — sonst Fallback "Baka".
const bekannteBenutzer = ['Baka', 'Tinker', 'Dustin'];
String selectedUser = bekannteBenutzer.contains(_navidrome.benutzer)
? _navidrome.benutzer
: 'Baka';
bool verbindet = false; bool verbindet = false;
bool obscure = true; bool obscure = true;
String? fehler; String? fehler;
+91
View File
@@ -0,0 +1,91 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'theme.dart';
/// Blendet [child] beim ersten Erscheinen leicht gestaffelt ein — statt
/// abrupt aufzupoppen, faded und rutscht es ein kleines Stück von unten
/// heran. Je weiter hinten in der Liste, desto später beginnt es, damit die
/// Liste als Ganzes fließt statt gleichzeitig aufzublitzen.
///
/// Läuft genau einmal je Element-Lebenszeit: ein Rebuild (etwa weil sich der
/// Song-Titel ändert) startet die Animation nicht erneut.
class EinblendItem extends StatefulWidget {
const EinblendItem({super.key, required this.child, required this.index});
final Widget child;
/// Position in der Liste — bestimmt die Verzögerung vor dem Start.
final int index;
@override
State<EinblendItem> createState() => _EinblendItemState();
}
class _EinblendItemState extends State<EinblendItem>
with SingleTickerProviderStateMixin {
/// Ab Index 12 keine zusätzliche Verzögerung mehr — sonst wirkte eine
/// lange Liste beim ersten Erscheinen träge, weil die letzten Zeilen erst
/// spät anfingen.
static const _deckel = 12;
static const _versatzProItem = Duration(milliseconds: 25);
late final AnimationController _controller = AnimationController(
vsync: this,
duration: MeloMotion.normal,
);
late final Animation<double> _kurve = CurvedAnimation(
parent: _controller,
curve: MeloMotion.curve,
);
bool _gestartet = false;
// Kein Start in initState: die Entscheidung hängt an MediaQuery
// (Reduce-Motion), und darauf darf man dort noch nicht zugreifen — siehe
// LaufBalken. didChangeDependencies läuft unmittelbar danach und ein
// _gestartet-Merker verhindert, dass ein späterer Aufruf (z. B. durch
// eine geänderte Textgröße) die Animation erneut anstößt.
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_gestartet) return;
_gestartet = true;
if (MeloMotion.ruhig(context, MeloMotion.normal) == Duration.zero) {
_controller.value = 1;
return;
}
final verzoegerung = _versatzProItem * math.min(widget.index, _deckel);
if (verzoegerung == Duration.zero) {
_controller.forward();
} else {
Future.delayed(verzoegerung, () {
if (mounted) _controller.forward();
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _kurve,
builder: (context, child) => Opacity(
opacity: _kurve.value,
child: Transform.translate(
offset: Offset(0, MeloSpace.sm * (1 - _kurve.value)),
child: child,
),
),
child: widget.child,
);
}
}
+53
View File
@@ -248,6 +248,59 @@ void main() {
findsOneWidget); findsOneWidget);
}); });
testWidgets(
'Der Anmelde-Dialog schlägt den bekannten Navidrome-Benutzer vor, '
'nicht immer "Baka"', (tester) async {
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final nav = NavidromeService()
..setCredentials('https://musik.baka-net.de', 'Tinker', 'falsch');
await tester.pumpWidget(_wrap(auth,
db: db, categories: categories, navidrome: nav));
await tester.pumpAndSettle();
await tester.tap(find.text('Beim Baka-Konto anmelden'));
await tester.pumpAndSettle();
final feld = tester.widget<TextField>(find.byType(TextField).first);
expect(feld.controller!.text, 'Tinker');
});
testWidgets(
'Erfolgreiches manuelles Anmelden übernimmt das neue Passwort auch '
'für Navidrome — sonst kommt der Auto-Login-Fehler nach Ablauf des '
'Baka-Tokens garantiert wieder', (tester) async {
final auth = BakaAuth(
client: MockClient((request) async {
final body = jsonDecode(request.body) as Map<String, dynamic>;
if (body['password'] == 'neuesPasswort') {
return http.Response(jsonEncode({'token': 'jwt-neu'}), 200);
}
return http.Response('', 500);
}),
speicher: _MemorySpeicher(),
);
final nav = NavidromeService()
..setCredentials('https://musik.baka-net.de', 'Tinker', 'altesPasswort');
await tester.pumpWidget(_wrap(auth,
db: db, categories: categories, navidrome: nav));
await tester.pumpAndSettle();
await tester.tap(find.text('Beim Baka-Konto anmelden'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'neuesPasswort');
await tester.tap(find.text('Anmelden'));
await tester.pumpAndSettle();
expect(nav.passwort, 'neuesPasswort');
expect(nav.benutzer, 'Tinker');
expect(auth.istAngemeldet, isTrue);
});
testWidgets( testWidgets(
'Server-User meldet sich im Hintergrund mit denselben Zugangsdaten ' 'Server-User meldet sich im Hintergrund mit denselben Zugangsdaten '
'beim Baka-Konto an', (tester) async { 'beim Baka-Konto an', (tester) async {
@@ -0,0 +1,100 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/downloads/server_titel_screen.dart';
import 'package:melo/library/database.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/services/navidrome_service.dart';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff:
/// der spricht über Plattform-Kanäle, die im Widget-Test nicht existieren
/// und mit `MissingPluginException` scheitern würden. Alles andere
/// (Warteschlange, Mediendaten) bleibt echtes Handler-Verhalten.
class _FakeAudioHandler extends MeloAudioHandler {
_FakeAudioHandler(MeloDb db) : super(db: db);
@override
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
return 0;
}
}
void main() {
late MeloDb db;
late _FakeAudioHandler handler;
void stelleKanaeleRuhig() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp/melo-test',
);
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
}
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
stelleKanaeleRuhig();
db = MeloDb(NativeDatabase.memory());
handler = _FakeAudioHandler(db);
});
tearDown(() {
handler.dispose();
});
Widget wrap(Widget child, PlayerExpansionController expansion) =>
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: MaterialApp(home: child),
);
testWidgets('Titel antippen öffnet den Vollbild-Player', (tester) async {
final navidrome = NavidromeService()
..setCredentials('https://musik.baka-net.de', 'Baka', 'geheim');
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
await tester.pumpWidget(wrap(
ServerTitelScreen(
titel: 'Ablaze',
navidrome: navidrome,
holeTitel: () async => const [
SubsonicSong(id: 's1', titel: 'Feuer', kuenstler: 'X', dauerSekunden: 120),
],
),
expansion,
));
await tester.pumpAndSettle();
expect(expansion.progress, 0.0);
await tester.tap(find.text('Feuer'));
await tester.pumpAndSettle();
expect(expansion.progress, 1.0);
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo beim Abbau des
// Baums als offener Fake-Timer stehen.
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
});
}
+15 -5
View File
@@ -7,6 +7,7 @@ import 'package:melo/library/library_service.dart';
import 'package:melo/library/playlist_service.dart'; import 'package:melo/library/playlist_service.dart';
import 'package:melo/main.dart'; import 'package:melo/main.dart';
import 'package:melo/player/audio_handler.dart'; import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/services/baka_auth.dart'; import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart'; import 'package:melo/services/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart'; import 'package:melo/services/melo_cloud_service.dart';
@@ -52,6 +53,7 @@ void main() {
late DownloadService downloads; late DownloadService downloads;
late SyncService sync; late SyncService sync;
MeloAudioHandler? handler; MeloAudioHandler? handler;
PlayerExpansionController? expansion;
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
@@ -81,10 +83,14 @@ void main() {
// Widget-Baum sie beim Abbauen selbst schließen, bliebe ein Aufräum-Timer // Widget-Baum sie beim Abbauen selbst schließen, bliebe ein Aufräum-Timer
// von drift im Test-Rahmen hängen — und ein anschließendes db.close() // von drift im Test-Rahmen hängen — und ein anschließendes db.close()
// käme gar nicht mehr zurück (der Testlauf hinge unbegrenzt). // käme gar nicht mehr zurück (der Testlauf hinge unbegrenzt).
Widget bauen() { Widget bauen(WidgetTester tester) {
// Der Audio-Handler erst hier: sein Zwischenspeicher greift auf einen // Der Audio-Handler erst hier: sein Zwischenspeicher greift auf einen
// Plattform-Kanal zu, den es im setUp noch nicht gibt. // Plattform-Kanal zu, den es im setUp noch nicht gibt.
handler ??= MeloAudioHandler(db: db); handler ??= MeloAudioHandler(db: db);
// PlayerExpansionController sitzt seit dem Provider-Scope-Fix (main.dart)
// oberhalb von MaterialApp/HomeShell, nicht mehr innerhalb von
// HomeShell.build() — hier deshalb dieselbe Verdrahtung nachgebaut.
expansion ??= PlayerExpansionController(vsync: tester);
return MultiProvider( return MultiProvider(
providers: [ providers: [
Provider<MeloDb>.value(value: db), Provider<MeloDb>.value(value: db),
@@ -99,6 +105,8 @@ void main() {
ChangeNotifierProvider<YtSearchService>.value(value: ytSearch), ChangeNotifierProvider<YtSearchService>.value(value: ytSearch),
ChangeNotifierProvider<DownloadService>.value(value: downloads), ChangeNotifierProvider<DownloadService>.value(value: downloads),
ChangeNotifierProvider<SyncService>.value(value: sync), ChangeNotifierProvider<SyncService>.value(value: sync),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion!),
], ],
child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()), child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()),
); );
@@ -113,6 +121,8 @@ void main() {
await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(const SizedBox());
handler?.dispose(); handler?.dispose();
handler = null; handler = null;
expansion?.dispose();
expansion = null;
// Die Zeit ein Stück vorstellen: beim Abbauen melden sich die // Die Zeit ein Stück vorstellen: beim Abbauen melden sich die
// Datenbank-Ströme ab und planen dafür eine Aufräum-Aufgabe ein. Ein // Datenbank-Ströme ab und planen dafür eine Aufräum-Aufgabe ein. Ein
// pump() ohne Dauer lässt sie liegen, und der Test-Rahmen meldet dann // pump() ohne Dauer lässt sie liegen, und der Test-Rahmen meldet dann
@@ -126,7 +136,7 @@ void main() {
); );
testWidgets('alle vier Bereiche stehen im Hauptmenü', (tester) async { testWidgets('alle vier Bereiche stehen im Hauptmenü', (tester) async {
await tester.pumpWidget(bauen()); await tester.pumpWidget(bauen(tester));
await tester.pump(); await tester.pump();
final leiste = final leiste =
@@ -138,7 +148,7 @@ void main() {
testWidgets('jeder Bereich ist beschriftet und liegt auf dem Bildschirm', testWidgets('jeder Bereich ist beschriftet und liegt auf dem Bildschirm',
(tester) async { (tester) async {
await tester.pumpWidget(bauen()); await tester.pumpWidget(bauen(tester));
await tester.pump(); await tester.pump();
final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio; final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio;
@@ -163,7 +173,7 @@ void main() {
final ziel = i; final ziel = i;
testWidgets('Tipp auf "${hauptmenue[ziel]}" wechselt dorthin', testWidgets('Tipp auf "${hauptmenue[ziel]}" wechselt dorthin',
(tester) async { (tester) async {
await tester.pumpWidget(bauen()); await tester.pumpWidget(bauen(tester));
await tester.pump(); await tester.pump();
await tester.tap(eintrag(hauptmenue[ziel])); await tester.tap(eintrag(hauptmenue[ziel]));
@@ -182,7 +192,7 @@ void main() {
testWidgets('das Hauptmenü klebt am unteren Rand', (tester) async { testWidgets('das Hauptmenü klebt am unteren Rand', (tester) async {
// Läge es woanders, wäre es kein Hauptmenü mehr. // Läge es woanders, wäre es kein Hauptmenü mehr.
await tester.pumpWidget(bauen()); await tester.pumpWidget(bauen(tester));
await tester.pump(); await tester.pump();
final menue = tester.getRect(find.byType(BottomNavigationBar)); final menue = tester.getRect(find.byType(BottomNavigationBar));
+76
View File
@@ -7,6 +7,7 @@ import 'package:melo/library/library_service.dart';
import 'package:melo/library/playlist_service.dart'; import 'package:melo/library/playlist_service.dart';
import 'package:melo/main.dart'; import 'package:melo/main.dart';
import 'package:melo/player/audio_handler.dart'; import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/services/baka_auth.dart'; import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart'; import 'package:melo/services/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart'; import 'package:melo/services/melo_cloud_service.dart';
@@ -39,6 +40,7 @@ void main() {
final playlists = PlaylistService(db); final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db); final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher()); final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider( await tester.pumpWidget(MultiProvider(
providers: [ providers: [
@@ -59,6 +61,8 @@ void main() {
ChangeNotifierProvider<SyncService>( ChangeNotifierProvider<SyncService>(
create: (_) => SyncService( create: (_) => SyncService(
db: db, cloud: MeloCloudService(auth: auth))), db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
], ],
child: const MaterialApp(home: HomeShell()), child: const MaterialApp(home: HomeShell()),
)); ));
@@ -70,6 +74,78 @@ void main() {
['Meine Musik', 'Suchen', 'Download', 'Favoriten', 'YT-Suche']); ['Meine Musik', 'Suchen', 'Download', 'Favoriten', 'YT-Suche']);
handler.dispose(); handler.dispose();
expansion.dispose();
await db.close(); await db.close();
}); });
testWidgets('Zurück-Geste auf einem Nicht-Start-Tab wechselt zu '
'"Meine Musik" statt die App zu minimieren', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<YtSearchService>(
create: (_) => YtSearchService(auth: auth)),
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
ChangeNotifierProvider<SyncService>(
create: (_) => SyncService(
db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
await tester.pump();
// Auf "Favoriten" wechseln (Index 3) — kein Navigator.push, nur der Tab.
await tester.tap(find.descendant(
of: find.byType(BottomNavigationBar), matching: find.text('Favoriten')));
await tester.pump();
expect(
tester
.widget<BottomNavigationBar>(find.byType(BottomNavigationBar))
.currentIndex,
3);
// System-Zurück-Geste simulieren — derselbe Weg, über den auch das
// echte Android-Zurück-Gesten-Signal bei WidgetsApp ankommt.
final widgetsAppState = tester.state(find.byType(WidgetsApp));
// ignore: avoid_dynamic_calls
await (widgetsAppState as dynamic).didPopRoute();
await tester.pump();
// Ohne Abfangen bliebe der Tab unverändert (Flutter minimiert dann die
// App statt zu navigieren) — die Geste muss stattdessen zu Tab 0 führen.
expect(
tester
.widget<BottomNavigationBar>(find.byType(BottomNavigationBar))
.currentIndex,
0);
// Baum abbauen, bevor der Test endet: der Positions-Timer des Audio-
// Handlers läuft sonst über das Testende hinaus weiter, und der
// Test-Rahmen meldet einen offenen Timer (wie in hauptmenue_test.dart
// dokumentiert). Aus demselben Grund bewusst kein db.close() nach
// einem tester.tap.
await tester.pumpWidget(const SizedBox());
handler.dispose();
expansion.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
} }
+21
View File
@@ -0,0 +1,21 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/library_service.dart';
// Bug (Tinker): Auf Android zeigten die Einstellungen zwei Buttons mit
// identischer Aufgabe ("Musik scannen" und "Erneut scannen") — beide riefen
// auf Android denselben Geräte-weiten MediaStore-Scan auf. Die Sichtbarkeit
// des zweiten Buttons hängt eigentlich von Platform.isAndroid ab, das sich
// in Tests nicht sauber auf "Android" umschalten lässt — deshalb ist die
// Entscheidung hier als pure, plattformunabhängig testbare Funktion
// ausgelagert.
void main() {
group('zeigeErneutScannenButton', () {
test('auf Android ausgeblendet (identisch zu "Musik scannen")', () {
expect(zeigeErneutScannenButton(true), isFalse);
});
test('auf Desktop angezeigt (echter Unterschied: bekannte Ordner erneut scannen)', () {
expect(zeigeErneutScannenButton(false), isTrue);
});
});
}
+73
View File
@@ -0,0 +1,73 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/database.dart';
Future<void> _insertSong(MeloDb db, String id, {bool deleted = false}) {
return db.into(db.songs).insert(SongsCompanion.insert(
id: id,
path: '/$id.mp3',
title: id,
dateAddedMs: 0,
updatedAtMs: 0,
deleted: Value(deleted),
));
}
Future<void> _play(MeloDb db, String songId, int playedAtMs) {
return db.into(db.playbackHistory).insert(PlaybackHistoryCompanion.insert(
songId: songId,
positionMs: 0,
playedAtMs: playedAtMs,
));
}
void main() {
test('ein mehrfach gespielter Song erscheint nur einmal, an der Position '
'seines jüngsten Abspielens', () async {
final db = MeloDb(NativeDatabase.memory());
await _insertSong(db, 'a');
await _insertSong(db, 'b');
await _play(db, 'a', 1000);
await _play(db, 'b', 2000);
await _play(db, 'a', 3000); // a erneut gespielt, jetzt jüngster Play
final result = await db.watchRecentlyPlayed().first;
expect(result.map((s) => s.id).toList(), ['a', 'b']);
await db.close();
});
test('mehrere Songs erscheinen absteigend nach ihrem letzten Abspielen',
() async {
final db = MeloDb(NativeDatabase.memory());
await _insertSong(db, 'a');
await _insertSong(db, 'b');
await _insertSong(db, 'c');
await _play(db, 'a', 1000);
await _play(db, 'b', 3000);
await _play(db, 'c', 2000);
final result = await db.watchRecentlyPlayed().first;
expect(result.map((s) => s.id).toList(), ['b', 'c', 'a']);
await db.close();
});
test('ein gelöschter Song mit Wiedergabe-Historie erscheint nicht',
() async {
final db = MeloDb(NativeDatabase.memory());
await _insertSong(db, 'a', deleted: true);
await _insertSong(db, 'b');
await _play(db, 'a', 2000);
await _play(db, 'b', 1000);
final result = await db.watchRecentlyPlayed().first;
expect(result.map((s) => s.id).toList(), ['b']);
await db.close();
});
}
+132
View File
@@ -0,0 +1,132 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/library/song_list.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/settings/app_settings.dart';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff:
/// der spricht über Plattform-Kanäle, die im Widget-Test nicht existieren
/// und mit `MissingPluginException` scheitern würden. Alles andere
/// (Warteschlange, Mediendaten) bleibt echtes Handler-Verhalten.
class _FakeAudioHandler extends MeloAudioHandler {
_FakeAudioHandler(MeloDb db) : super(db: db);
@override
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
return 0;
}
}
void main() {
late MeloDb db;
late CategoryService kategorien;
late PlaylistService playlists;
late AppSettings einstellungen;
late _FakeAudioHandler handler;
void stelleKanaeleRuhig() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp/melo-test',
);
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
}
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
stelleKanaeleRuhig();
db = MeloDb(NativeDatabase.memory());
kategorien = CategoryService(db);
playlists = PlaylistService(db);
einstellungen = AppSettings();
await einstellungen.init();
handler = _FakeAudioHandler(db);
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: const Value('Neoni'),
dateAddedMs: 0,
updatedAtMs: 0,
));
});
tearDown(() {
handler.dispose();
kategorien.dispose();
playlists.dispose();
});
Song derSong() => Song(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: 'Neoni',
dateAddedMs: 0,
updatedAtMs: 0,
deleted: false,
playCount: 0,
categoriesEdited: false,
metadataEdited: false,
);
testWidgets('Song antippen öffnet den Vollbild-Player mit großem Cover',
(tester) async {
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: MaterialApp(
home: Scaffold(
body: SongZeile(
song: derSong(),
warteschlange: [derSong()],
index: 0,
),
),
),
),
);
expect(expansion.progress, 0.0);
await tester.tap(find.text('Roses'));
await tester.pumpAndSettle();
expect(expansion.progress, 1.0);
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo (Favoriten-Herz)
// beim Abbau des Baums als offener Fake-Timer stehen — dieselbe
// Aufräum-Zeile wie in song_zeile_lauf_test.dart.
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
});
}
+30
View File
@@ -0,0 +1,30 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:melo/player/audio_handler.dart';
void main() {
group('toAudioServiceShuffleMode', () {
test('true wird zu AudioServiceShuffleMode.all', () {
expect(toAudioServiceShuffleMode(true), AudioServiceShuffleMode.all);
});
test('false wird zu AudioServiceShuffleMode.none', () {
expect(toAudioServiceShuffleMode(false), AudioServiceShuffleMode.none);
});
});
group('toAudioServiceRepeatMode', () {
test('LoopMode.one wird zu AudioServiceRepeatMode.one', () {
expect(toAudioServiceRepeatMode(LoopMode.one), AudioServiceRepeatMode.one);
});
test('LoopMode.all wird zu AudioServiceRepeatMode.all', () {
expect(toAudioServiceRepeatMode(LoopMode.all), AudioServiceRepeatMode.all);
});
test('LoopMode.off wird zu AudioServiceRepeatMode.none', () {
expect(toAudioServiceRepeatMode(LoopMode.off), AudioServiceRepeatMode.none);
});
});
}
+171
View File
@@ -0,0 +1,171 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:melo/player/player_expansion_controller.dart';
// Minimaler Host, der dieselbe Provider/PopScope-Verdrahtung wie HomeShell
// nachstellt, ohne die volle App (Audio-Handler etc.) hochfahren zu müssen.
// Der eigentliche HomeShell-Umbau wird direkt gegen main.dart getestet,
// sobald Task 4/5 die Stack-Struktur eingeführt haben; dieser erste Test
// sichert nur die PopScope-Erweiterung ab, isoliert an einem Test-Double.
class _TestShell extends StatefulWidget {
const _TestShell({this.tabContent = const SizedBox.shrink()});
final Widget tabContent;
@override
State<_TestShell> createState() => _TestShellState();
}
class _TestShellState extends State<_TestShell> with TickerProviderStateMixin {
late final PlayerExpansionController _controller;
@override
void initState() {
super.initState();
_controller = PlayerExpansionController(vsync: this);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Eigener AnimatedBuilder um PopScope, damit canPop bei jedem
// _controller-Tick neu ausgewertet wird (Reaktivitäts-Regel aus dem
// Plan) — ohne ihn bliebe canPop auf dem Wert des allerersten Builds
// eingefroren, weil sonst nichts hier ein Rebuild auslöst.
return ChangeNotifierProvider<PlayerExpansionController>.value(
value: _controller,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => PopScope(
canPop: _controller.progress == 0,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _controller.close(context);
},
child: child!,
),
// Derselbe Stack-Aufbau wie HomeShell (Task 4): Blur-Layer und
// Tab-Blocker bekommen jeweils ihren eigenen AnimatedBuilder, NICHT
// nur den äußeren aus dem PopScope-Consumer oben (Reaktivitäts-Regel
// aus dem Plan) — sonst würde dieser Test den genau davor gefundenen
// Bug (Review-Panel) nicht abdecken.
child: Scaffold(
body: Stack(
children: [
AnimatedBuilder(
animation: _controller,
builder: (context, child) => IgnorePointer(
ignoring: _controller.progress > 0,
child: child,
),
child: widget.tabContent,
),
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final p = _controller.progress;
if (p == 0) return const SizedBox.shrink();
return Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: p * 20.0, sigmaY: p * 20.0),
child: Container(color: Colors.transparent),
),
);
},
),
],
),
),
),
);
}
}
void main() {
testWidgets('Zurück-Taste schließt den Player statt zu poppen, wenn offen',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: _TestShell()));
final controller =
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
controller.open(tester.element(find.byType(_TestShell)));
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
// pageBack() sucht einen sichtbaren Zurück-Button, den es hier ohne
// zweite Route nicht gibt (siehe Plan-Hinweis für den Umsetzer) —
// Navigator.maybePop() löst denselben PopScope-Callback aus.
await Navigator.maybePop(tester.element(find.byType(_TestShell)));
await tester.pumpAndSettle();
expect(controller.progress, 0.0);
});
testWidgets('Blur-Layer erscheint erst, wenn progress > 0', (tester) async {
await tester.pumpWidget(const MaterialApp(home: _TestShell()));
expect(find.byType(BackdropFilter), findsNothing);
final controller =
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
controller.open(tester.element(find.byType(_TestShell)));
// Ein zusätzlicher, leerer pump() startet den Ticker — sein erster Tick
// liefert laut Flutter-Konvention elapsed=0 (Baseline), erst der
// folgende pump(duration) rückt die Animation tatsächlich vorwärts.
// Ohne diesen Zwischenschritt bliebe progress nach nur einem
// pump(duration) fälschlich bei 0.0 (per Debug-Ausgabe verifiziert).
await tester.pump();
await tester.pump(const Duration(milliseconds: 50)); // mitten in der Animation
expect(find.byType(BackdropFilter), findsOneWidget);
await tester.pumpAndSettle();
});
testWidgets('Tab-Inhalt ist bei offenem Player nicht mehr antippbar',
(tester) async {
var getappt = false;
await tester.pumpWidget(MaterialApp(
home: _TestShell(
tabContent: GestureDetector(
onTap: () => getappt = true,
child: const SizedBox(width: 200, height: 200),
),
),
));
final controller =
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
controller.open(tester.element(find.byType(_TestShell)));
await tester.pumpAndSettle();
await tester.tap(find.byType(GestureDetector), warnIfMissed: false);
expect(getappt, isFalse);
});
testWidgets(
'Kompletter Zyklus: Ziehen öffnet, Blur erscheint, Zurück-Taste schließt',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: _TestShell()));
final ctx = tester.element(find.byType(_TestShell));
final controller =
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
controller.dragBy(-250);
await tester.pump();
expect(controller.progress, closeTo(250 / oeffnenReferenzHoehe, 0.01));
expect(find.byType(BackdropFilter), findsOneWidget);
controller.dragEnd(ctx, -250, 0, ausVollbild: false);
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
// pageBack() sucht einen sichtbaren Zurück-Button, den es hier ohne
// zweite Route nicht gibt (siehe Plan-Hinweis für den Umsetzer) —
// Navigator.maybePop() löst denselben PopScope-Callback aus.
await Navigator.maybePop(ctx);
await tester.pumpAndSettle();
expect(controller.progress, 0.0);
expect(find.byType(BackdropFilter), findsNothing);
});
}
@@ -0,0 +1,181 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/library_service.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/main.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/mini_player.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/navidrome_service.dart';
import 'package:melo/services/offline_mode.dart';
import 'package:melo/services/sync_service.dart';
import 'package:melo/services/yt_download_service.dart';
import 'package:melo/services/yt_search_service.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
// Dieselbe _MemorySpeicher/Provider-Verdrahtung wie test/home_shell_test.dart
// — von dort übernommen, nicht neu erfunden.
class _MemorySpeicher implements TokenSpeicher {
final Map<String, String> werte = {};
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets(
'Echtes HomeShell: Blur-Layer reagiert live auf progress, nicht erst '
'beim nächsten fremden Rebuild', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<YtSearchService>(
create: (_) => YtSearchService(auth: auth)),
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
ChangeNotifierProvider<SyncService>(
create: (_) =>
SyncService(db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
await tester.pump();
expect(find.byType(BackdropFilter), findsNothing);
// PlayerExpansionController sitzt seit dem Provider-Scope-Fix oberhalb
// von MaterialApp/HomeShell (analog zu MeloApps MultiProvider in
// main.dart), nicht mehr innerhalb von HomeShell.build() — jedes Element
// im Baum sieht denselben Controller, hier über ein Element unterhalb von
// HomeShell (MiniPlayer, immer im Stack, auch ohne laufenden Titel).
final descendantContext = tester.element(find.byType(MiniPlayer));
final controller = Provider.of<PlayerExpansionController>(
descendantContext,
listen: false);
controller.open(descendantContext);
// Absichtlich NUR EINEN kurzen Zwischenschritt pumpen (nicht
// pumpAndSettle) — das ist der entscheidende Unterschied zum
// `_TestShell`-Test: er beweist, dass der Blur-Layer OHNE einen
// zusätzlichen, fremden setState()-Trigger (z.B. Tab-Wechsel) live
// erscheint.
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.byType(BackdropFilter), findsOneWidget);
await tester.pumpAndSettle();
// Baum abbauen, bevor der Test endet — derselbe Grund wie in
// test/home_shell_test.dart (offener Positions-Timer des Audio-Handlers).
await tester.pumpWidget(const SizedBox());
handler.dispose();
expansion.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
testWidgets(
'State-Persistenz: _CoverGrundState bleibt über einen '
'open()/close()/open()-Zyklus erhalten (Spec-Vorteil ggü. dem früheren '
'Navigator.push, das die Route bei jedem Schließen disposte)',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<YtSearchService>(
create: (_) => YtSearchService(auth: auth)),
ChangeNotifierProvider<DownloadService>(
create: (_) =>
DownloadService(db: db, navidrome: NavidromeService())),
ChangeNotifierProvider<SyncService>(
create: (_) =>
SyncService(db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
await tester.pump();
// _CoverGrund ist privat (now_playing_screen.dart) — von außen per
// runtimeType-Namen gefunden statt per find.byType, das den Typ
// importierbar/öffentlich voraussetzen würde. skipOffstage: false ist
// hier nötig: bei progress == 0 steckt NowPlayingScreen in einem
// Visibility(visible: false, maintainState: true) — genau das soll
// dieser Test ja beweisen —, und Flutters Standard-Finder überspringen
// Offstage-Elemente per Default (per Debug-Ausgabe verifiziert).
final coverGrundFinder = find.byWidgetPredicate(
(w) => w.runtimeType.toString() == '_CoverGrund',
skipOffstage: false);
expect(coverGrundFinder, findsOneWidget,
reason: 'NowPlayingScreen bleibt dank Visibility(maintainState: '
'true) (Task 5) von Anfang an im Baum, auch bei progress == 0.');
final zustandVorher = tester.state(coverGrundFinder);
final descendantContext = tester.element(find.byType(MiniPlayer));
final controller = Provider.of<PlayerExpansionController>(
descendantContext,
listen: false);
controller.open(descendantContext);
await tester.pumpAndSettle();
controller.close(descendantContext);
await tester.pumpAndSettle();
controller.open(descendantContext);
await tester.pumpAndSettle();
final zustandNachher = tester.state(coverGrundFinder);
expect(identical(zustandVorher, zustandNachher), isTrue,
reason: '_CoverGrundState wäre beim früheren Navigator.push-Ansatz '
'bei jedem Schließen disposed und neu erzeugt worden.');
await tester.pumpWidget(const SizedBox());
handler.dispose();
expansion.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/library/song_list.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/now_playing_screen.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:melo/shared/favorite_button.dart';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff —
/// dieselbe Klasse wie in song_list_navigation_test.dart, dort erprobt.
class _FakeAudioHandler extends MeloAudioHandler {
_FakeAudioHandler(MeloDb db) : super(db: db);
@override
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
return 0;
}
}
void main() {
late MeloDb db;
late CategoryService kategorien;
late PlaylistService playlists;
late AppSettings einstellungen;
late _FakeAudioHandler handler;
void stelleKanaeleRuhig() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp/melo-test',
);
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
}
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
stelleKanaeleRuhig();
db = MeloDb(NativeDatabase.memory());
kategorien = CategoryService(db);
playlists = PlaylistService(db);
einstellungen = AppSettings();
await einstellungen.init();
handler = _FakeAudioHandler(db);
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: const Value('Neoni'),
dateAddedMs: 0,
updatedAtMs: 0,
));
});
tearDown(() {
handler.dispose();
kategorien.dispose();
playlists.dispose();
});
Song derSong() => Song(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: 'Neoni',
dateAddedMs: 0,
updatedAtMs: 0,
deleted: false,
playCount: 0,
categoriesEdited: false,
metadataEdited: false,
);
// Startet die Wiedergabe über denselben Weg wie in
// song_list_navigation_test.dart (Tap auf eine SongZeile) — dieser Weg ist
// dort nachweislich unproblematisch und übt die echte SongZeile→MediaItem
// -Umwandlung. Seit dem Umbau auf PlayerExpansionController (Plan
// 2026-08-29-blur-oeffnen-transition, Task 3/5) ist NowPlayingScreen kein
// per Navigator.push geöffneter Screen mehr, sondern reiner Inhalt — hier
// deshalb direkt in einen eigenen Scaffold gepumpt (Task 5 Step 3), statt
// eine Route zu pushen. Der Handler behält den zuvor gestarteten Titel in
// seinem mediaItem-Stream, den NowPlayingScreen darüber sofort sieht.
Future<PlayerExpansionController> oeffnePlayer(WidgetTester tester) async {
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
final providers = [
Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
];
await tester.pumpWidget(
MultiProvider(
providers: providers,
child: MaterialApp(
home: Scaffold(
body: SongZeile(
song: derSong(),
warteschlange: [derSong()],
index: 0,
),
),
),
),
);
await tester.tap(find.text('Roses'));
await tester.pumpAndSettle();
expect(expansion.progress, 1.0);
await tester.pumpWidget(
MultiProvider(
providers: providers,
child: MaterialApp(
home: Scaffold(body: const NowPlayingScreen()),
),
),
);
await tester.pumpAndSettle();
return expansion;
}
Future<void> raeumeAuf(WidgetTester tester) async {
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo (Favoriten-Herz)
// beim Abbau des Baums als offener Fake-Timer stehen — dieselbe
// Aufräum-Zeile wie in song_list_navigation_test.dart.
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
}
testWidgets(
'Herz sitzt neben dem Songnamen, nicht mehr in der AppBar',
(tester) async {
await oeffnePlayer(tester);
expect(find.byType(NowPlayingScreen), findsOneWidget);
// NowPlayingScreen ist seit dem Overlay-Umbau (Task 5) kein Scaffold/
// keine Route mehr und hat gar keine AppBar mehr — die frühere
// "nicht mehr in der AppBar"-Prüfung ist jetzt trivial durch den Umbau
// selbst erfüllt.
expect(find.byType(AppBar), findsNothing);
// Aber sichtbar auf dem Bildschirm, in derselben Row wie der Titel.
final titelRow = find
.ancestor(of: find.text('Roses'), matching: find.byType(Row))
.first;
final herzBeimTitel = find.descendant(
of: titelRow,
matching: find.byType(FavoriteButton),
);
expect(herzBeimTitel, findsOneWidget);
await raeumeAuf(tester);
});
testWidgets('Ziehen nach unten im Vollbild-Player verringert progress live',
(tester) async {
// _TestShell (home_shell_expansion_test.dart) hat bewusst keinen echten
// MeloAudioHandler-Provider (siehe dortiger Kommentar) — ein echter
// NowPlayingScreen bräuchte den aber für context.read<MeloAudioHandler>().
// Dieser Test läuft deshalb hier, wo oeffnePlayer() bereits alle nötigen
// Provider bereitstellt (Plan Task 8 nennt now_playing_screen_test.dart
// selbst als Alternative zu home_shell_expansion_test.dart).
final expansion = await oeffnePlayer(tester);
expect(expansion.progress, 1.0);
await tester.drag(find.byType(NowPlayingScreen), const Offset(0, 150));
await tester.pump();
expect(expansion.progress, lessThan(1.0));
// dragEnd() (ausgelöst durch tester.drag) startet ein fling() — ohne
// pumpAndSettle bliebe dessen Ticker beim Testende aktiv.
await tester.pumpAndSettle();
await raeumeAuf(tester);
});
}
@@ -0,0 +1,128 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/mini_player.dart';
import 'package:melo/player/now_playing_screen.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff —
/// dieselbe Klasse wie in now_playing_heart_test.dart.
class _FakeAudioHandler extends MeloAudioHandler {
_FakeAudioHandler(MeloDb db) : super(db: db);
@override
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
return 0;
}
}
void main() {
void stelleKanaeleRuhig() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp/melo-test',
);
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
}
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets(
'NowPlayingScreen in einem während der Öffnen-Geste noch kleinen, '
'interpolierten Rechteck (progress ~0.4) wirft keinen '
'RenderFlex-Overflow', (tester) async {
TestWidgetsFlutterBinding.ensureInitialized();
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
stelleKanaeleRuhig();
final db = MeloDb(NativeDatabase.memory());
final kategorien = CategoryService(db);
final playlists = PlaylistService(db);
final einstellungen = AppSettings();
await einstellungen.init();
final handler = _FakeAudioHandler(db);
addTearDown(kategorien.dispose);
addTearDown(playlists.dispose);
// Ein echtes MediaItem, wie es beim Abspielen im mediaItem-Stream steht
// — NowPlayingScreen zeigt sonst nur "Nichts in Wiedergabe" und hat dann
// gar keinen Inhalt, der überlaufen könnte.
handler.mediaItem.add(const MediaItem(
id: 'song-1',
title: 'Roses',
artist: 'Neoni',
));
// Dieselbe Rect-Interpolation wie HomeShell._miniRect/_vollbildRect bei
// progress=0.4 auf der Standard-Testbildschirmgröße (800x600, siehe
// flutter_test-Default) — main.dart positioniert NowPlayingScreen
// während der Öffnen-Geste über genau so ein Positioned.fromRect mit
// Rect.lerp zwischen Mini- und Vollbild-Rechteck. Bei p>0.3 blendet der
// Inhalt laut Spec schon sichtbar ein (main.dart: Opacity((p-0.3)/0.7)),
// das Rechteck ist zu dem Zeitpunkt aber noch weit von der
// Vollbildgröße entfernt.
const p = 0.4;
const screenSize = Size(800, 600);
const miniHoehe = MiniPlayer.hoehe;
final unten = screenSize.height - miniHoehe - kBottomNavigationBarHeight;
final miniRect = Rect.fromLTWH(0, unten, screenSize.width, miniHoehe);
final vollbildRect =
Rect.fromLTWH(0, 0, screenSize.width, screenSize.height);
final rect = Rect.lerp(miniRect, vollbildRect, p)!;
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: MaterialApp(
home: Scaffold(
body: Stack(
children: [
Positioned.fromRect(
rect: rect,
child: const NowPlayingScreen(),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Aufräum-Reihenfolge wie in den übrigen Tests dieser Datei-Familie:
// erst den Baum abbauen, dann den Handler disposen (offener Positions-
// Timer, siehe now_playing_heart_test.dart).
await tester.pumpWidget(const SizedBox());
handler.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
}
@@ -0,0 +1,192 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/player/player_expansion_controller.dart';
void main() {
Widget mitMediaQuery(bool disableAnimations, WidgetBuilder builder) {
return MediaQuery(
data: MediaQueryData(disableAnimations: disableAnimations),
child: MaterialApp(home: Builder(builder: builder)),
);
}
testWidgets('progress startet bei 0', (tester) async {
late PlayerExpansionController controller;
await tester.pumpWidget(mitMediaQuery(false, (context) {
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
expect(controller.progress, 0.0);
controller.dispose();
});
testWidgets('dragBy nach oben erhöht progress, nach unten verringert ihn',
(tester) async {
late PlayerExpansionController controller;
await tester.pumpWidget(mitMediaQuery(false, (context) {
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.dragBy(-150); // halbe Referenzhöhe nach oben
expect(controller.progress, closeTo(0.5, 0.001));
controller.dragBy(75); // ein Viertel zurück nach unten
expect(controller.progress, closeTo(0.25, 0.001));
controller.dispose();
});
testWidgets('dragBy clampt auf [0, 1]', (tester) async {
late PlayerExpansionController controller;
await tester.pumpWidget(mitMediaQuery(false, (context) {
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.dragBy(-1000);
expect(controller.progress, 1.0);
controller.dragBy(2000);
expect(controller.progress, 0.0);
controller.dispose();
});
testWidgets('open() fliegt zu 1, close() fliegt zu 0', (tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(false, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.open(ctx);
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
controller.close(ctx);
await tester.pumpAndSettle();
expect(controller.progress, 0.0);
controller.dispose();
});
testWidgets('open()/close() springen bei disableAnimations sofort',
(tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(true, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.open(ctx);
expect(controller.progress, 1.0); // ohne pumpAndSettle sofort da
controller.close(ctx);
expect(controller.progress, 0.0);
controller.dispose();
});
testWidgets(
'dragEnd beim Öffnen: ausreichende Strecke nach oben fliegt zu 1',
(tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(false, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.dragBy(-200);
controller.dragEnd(ctx, -200, 0, ausVollbild: false); // Strecke > wischStreckeSchwelle (80)
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
controller.dispose();
});
testWidgets(
'dragEnd beim Öffnen: zu kurze, langsame Geste fällt zurück auf 0',
(tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(false, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.dragBy(-30);
controller.dragEnd(ctx, -30, 0, ausVollbild: false); // unter wischStreckeSchwelle
await tester.pumpAndSettle();
expect(controller.progress, 0.0);
controller.dispose();
});
testWidgets(
'dragEnd beim Schließen: ausreichende Strecke nach unten fliegt zu 0',
(tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(false, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.open(ctx);
await tester.pumpAndSettle();
controller.dragBy(200); // nach unten, Betrag > Referenzhöhe/2
controller.dragEnd(ctx, 200, 0, ausVollbild: true);
await tester.pumpAndSettle();
expect(controller.progress, 0.0);
controller.dispose();
});
testWidgets(
'dragEnd beim Schließen: zu kurze, langsame Geste bleibt bei 1',
(tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(false, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.open(ctx);
await tester.pumpAndSettle();
controller.dragBy(30);
controller.dragEnd(ctx, 30, 0, ausVollbild: true);
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
controller.dispose();
});
testWidgets(
'dragEnd bei bereits vollem Player: schwaches Fingerzittern nach oben '
'klappt ihn NICHT zu (asymmetrischer Bug — dragEnd darf die '
'Entscheidungsrichtung nicht aus dem Vorzeichen von strecke raten, '
'sondern muss den Aufrufer-Kontext kennen)', (tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(false, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.open(ctx);
await tester.pumpAndSettle();
// Der Player ist voll offen (progress==1). Der Nutzer verrutscht beim
// Versuch, einen Knopf zu treffen, leicht nach oben — eine winzige,
// negative Strecke weit unter der Wisch-Schwelle. Das darf gar keine
// Zustandsänderung auslösen, weil die Geste viel zu schwach ist — schon
// gar nicht ein Zuklappen, das würde "nach oben ziehen schließt den
// offenen Player" bedeuten, was es nicht tut.
controller.dragBy(-5);
controller.dragEnd(ctx, -5, 0, ausVollbild: true);
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
controller.dispose();
});
}
@@ -0,0 +1,150 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/library_service.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/main.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/mini_player.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/playlists/playlist_detail_screen.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/navidrome_service.dart';
import 'package:melo/services/offline_mode.dart';
import 'package:melo/services/sync_service.dart';
import 'package:melo/services/yt_download_service.dart';
import 'package:melo/services/yt_search_service.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _MemorySpeicher implements TokenSpeicher {
final Map<String, String> werte = {};
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff —
/// dieselbe Klasse wie in playlist_detail_navigation_test.dart.
class _FakeAudioHandler extends MeloAudioHandler {
_FakeAudioHandler(MeloDb db) : super(db: db);
@override
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
return 0;
}
}
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets(
'Per Navigator.push geöffneter PlaylistDetailScreen (wie aus '
'playlists_screen.dart:120) sieht PlayerExpansionController — echtes '
'HomeShell, echter Navigator.push, kein direkt daneben geklebter '
'Test-Provider (Review-Fund: Provider war bisher nur INNERHALB von '
'HomeShell.build() bereitgestellt, eine gepushte Route ist im Baum '
'aber ein Geschwister von HomeShell, kein Nachfahre)', (tester) async {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
final db = MeloDb(NativeDatabase.memory());
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: const Value('Neoni'),
dateAddedMs: 0,
updatedAtMs: 0,
));
final playlistId = await db.createPlaylist('Road Trip');
await db.addSongToPlaylist(playlistId, 'song-1', 0);
final playlist = Playlist(
id: playlistId,
name: 'Road Trip',
createdAtMs: 0,
updatedAtMs: 0,
deleted: false,
);
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = _FakeAudioHandler(db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
// Dieselbe Provider-Verdrahtung wie home_shell_real_expansion_test.dart,
// PlayerExpansionController aber jetzt OBERHALB von MaterialApp/HomeShell
// — genau wie MeloApps MultiProvider nach dem Fix (main.dart), nicht mehr
// nur INNERHALB von HomeShell.build() bereitgestellt.
await tester.pumpWidget(MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<YtSearchService>(
create: (_) => YtSearchService(auth: auth)),
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
ChangeNotifierProvider<SyncService>(
create: (_) =>
SyncService(db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
await tester.pump();
// Navigator.push GENAU wie playlists_screen.dart:120 — von einem Kontext
// INNERHALB des echten HomeShell-Baums aus (MiniPlayer steckt immer im
// Stack, auch ohne laufenden Titel), nicht von einem manuell daneben
// gebauten Test-Baum.
final homeShellContext = tester.element(find.byType(MiniPlayer));
Navigator.of(homeShellContext).push(MaterialPageRoute(
builder: (_) => PlaylistDetailScreen(playlist: playlist),
));
await tester.pumpAndSettle();
expect(find.text('Roses'), findsOneWidget);
// Tap wie ein echter Nutzer — löst intern playSongs() und danach
// context.read<PlayerExpansionController>().open(context) aus.
await tester.tap(find.text('Roses'));
await tester.pumpAndSettle();
// Bug-Symptom: die ProviderNotFoundException landet im try/catch von
// PlaylistDetailScreen und zeigt eine "Wiedergabe fehlgeschlagen"-
// Snackbar, statt dass der Player öffnet.
expect(find.textContaining('Wiedergabe fehlgeschlagen'), findsNothing,
reason: 'context.read<PlayerExpansionController>() darf hier keine '
'ProviderNotFoundException werfen.');
// Beweis, dass .open() tatsächlich denselben (einzigen) Controller
// erreicht hat, nicht nur, dass keine Exception flog.
expect(expansion.progress, 1.0);
// Aufräum-Reihenfolge wie home_shell_real_expansion_test.dart: erst den
// Baum abbauen, dann den Handler disposen (offener Positions-Timer).
await tester.pumpWidget(const SizedBox());
handler.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
}
+77
View File
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/player/now_playing_screen.dart';
void main() {
// WanderndesCover.build() liefert ein Positioned zurück (siehe
// now_playing_screen.dart) — Positioned braucht laut Flutter zwingend
// einen Stack als Vorfahren, sonst wirft es "Incorrect use of
// ParentDataWidget" (per Testlauf verifiziert). Genau so wird es auch in
// main.dart verwendet (direkt als Kind des HomeShell-Stacks), deshalb hier
// ebenfalls in einen Stack gebettet statt direkt als Scaffold.body.
testWidgets('Rect bei progress=0 entspricht dem Mini-Player-Rechteck',
(tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Stack(
children: [
WanderndesCover(
progress: 0,
artUri: null,
miniRect: const Rect.fromLTWH(0, 700, 400, 52),
vollbildRect: const Rect.fromLTWH(0, 0, 400, 800),
),
],
),
),
));
final box = tester.getRect(find.byType(WanderndesCover));
expect(box, const Rect.fromLTWH(0, 700, 400, 52));
});
testWidgets('Rect bei progress=1 entspricht dem Vollbild-Rechteck',
(tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Stack(
children: [
WanderndesCover(
progress: 1,
artUri: null,
miniRect: const Rect.fromLTWH(0, 700, 400, 52),
vollbildRect: const Rect.fromLTWH(0, 0, 400, 800),
),
],
),
),
));
final box = tester.getRect(find.byType(WanderndesCover));
expect(box, const Rect.fromLTWH(0, 0, 400, 800));
});
testWidgets('Rect bei progress=0.5 liegt in der Mitte', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Stack(
children: [
WanderndesCover(
progress: 0.5,
artUri: null,
miniRect: const Rect.fromLTWH(0, 700, 400, 52),
vollbildRect: const Rect.fromLTWH(0, 0, 400, 800),
),
],
),
),
));
final box = tester.getRect(find.byType(WanderndesCover));
expect(
box,
Rect.lerp(
const Rect.fromLTWH(0, 700, 400, 52),
const Rect.fromLTWH(0, 0, 400, 800),
0.5,
),
);
});
}
@@ -0,0 +1,125 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/playlists/playlist_detail_screen.dart';
import 'package:melo/settings/app_settings.dart';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff:
/// der spricht über Plattform-Kanäle, die im Widget-Test nicht existieren
/// und mit `MissingPluginException` scheitern würden. Alles andere
/// (Warteschlange, Mediendaten) bleibt echtes Handler-Verhalten.
class _FakeAudioHandler extends MeloAudioHandler {
_FakeAudioHandler(MeloDb db) : super(db: db);
@override
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
return 0;
}
}
void main() {
late MeloDb db;
late CategoryService kategorien;
late PlaylistService playlists;
late AppSettings einstellungen;
late _FakeAudioHandler handler;
late String playlistId;
void stelleKanaeleRuhig() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp/melo-test',
);
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
}
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
stelleKanaeleRuhig();
db = MeloDb(NativeDatabase.memory());
kategorien = CategoryService(db);
playlists = PlaylistService(db);
einstellungen = AppSettings();
await einstellungen.init();
handler = _FakeAudioHandler(db);
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: const Value('Neoni'),
dateAddedMs: 0,
updatedAtMs: 0,
));
playlistId = await db.createPlaylist('Road Trip');
await db.addSongToPlaylist(playlistId, 'song-1', 0);
});
tearDown(() {
handler.dispose();
kategorien.dispose();
playlists.dispose();
});
testWidgets(
'Song in einer Wiedergabeliste antippen öffnet den Vollbild-Player',
(tester) async {
final playlist = Playlist(
id: playlistId,
name: 'Road Trip',
createdAtMs: 0,
updatedAtMs: 0,
deleted: false,
);
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: MaterialApp(
home: PlaylistDetailScreen(playlist: playlist),
),
),
);
await tester.pumpAndSettle();
expect(expansion.progress, 0.0);
await tester.tap(find.text('Roses'));
await tester.pumpAndSettle();
expect(expansion.progress, 1.0);
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo beim Abbau des
// Baums als offener Fake-Timer stehen.
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
});
}
+38
View File
@@ -115,6 +115,44 @@ void main() {
expect(auth.benutzer, 'Baka'); expect(auth.benutzer, 'Baka');
}); });
test(
'autoAnmelden löst bei gleichzeitigen Aufrufen nur EINEN HTTP-Request aus '
'(Race Condition durch IndexedStack, das mehrere Tabs gleichzeitig baut)',
() async {
var aufrufe = 0;
final client = MockClient((_) async {
aufrufe++;
// Künstliche Verzögerung: simuliert, dass der erste Request noch
// nicht zurück ist, wenn der zweite Aufruf startet.
await Future.delayed(const Duration(milliseconds: 50));
return http.Response(jsonEncode({'token': 'jwt-tinker'}), 200);
});
final auth = BakaAuth(client: client, speicher: _MemorySpeicher());
await Future.wait([
auth.autoAnmelden('Tinker', 'geheim'),
auth.autoAnmelden('Tinker', 'geheim'),
]);
expect(aufrufe, 1);
expect(auth.istAngemeldet, isTrue);
expect(auth.benutzer, 'Tinker');
});
test(
'autoAnmelden merkt sich den Fehlergrund für die Diagnose, da es '
'selbst keinen Rückgabewert liefert', () async {
final client = MockClient((_) async => http.Response(
jsonEncode({'status': 'error', 'message': 'Falsches Passwort'}),
200));
final auth = BakaAuth(client: client, speicher: _MemorySpeicher());
await auth.autoAnmelden('Tinker', 'falsch');
expect(auth.letzterAutoLoginFehler, 'Falsches Passwort');
expect(auth.istAngemeldet, isFalse);
});
test('autoAnmelden tut nichts ohne Zugangsdaten', () async { test('autoAnmelden tut nichts ohne Zugangsdaten', () async {
var aufrufe = 0; var aufrufe = 0;
final client = MockClient((_) async { final client = MockClient((_) async {
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/shared/einblend_item.dart';
void main() {
Widget wrap(Widget child, {bool ruhig = false}) => MaterialApp(
home: MediaQuery(
data: MediaQueryData(disableAnimations: ruhig),
child: Scaffold(body: child),
),
);
double opacityVon(WidgetTester tester, Finder einblendItem) => tester
.widget<Opacity>(
find.descendant(of: einblendItem, matching: find.byType(Opacity)))
.opacity;
double versatzYVon(WidgetTester tester, Finder einblendItem) => tester
.widget<Transform>(
find.descendant(of: einblendItem, matching: find.byType(Transform)))
.transform
.getTranslation()
.y;
testWidgets('Opacity startet bei 0 und erreicht 1,0 nach dem Einblenden',
(tester) async {
await tester.pumpWidget(wrap(
const EinblendItem(index: 0, child: Text('Titel')),
));
final item = find.byType(EinblendItem);
expect(opacityVon(tester, item), 0);
await tester.pumpAndSettle();
expect(opacityVon(tester, item), 1);
expect(versatzYVon(tester, item), 0);
});
testWidgets(
'bei abgeschalteten Systemanimationen erscheint das Element sofort',
(tester) async {
await tester.pumpWidget(wrap(
const EinblendItem(index: 3, child: Text('Titel')),
ruhig: true,
));
final item = find.byType(EinblendItem);
// Bewusst kein weiterer pump() davor: die Reduce-Motion-Einstellung
// darf nicht erst nach einer Zeitverzögerung greifen.
expect(opacityVon(tester, item), 1);
expect(versatzYVon(tester, item), 0);
});
testWidgets('der Deckel bremst die Verzögerung ab Index 12',
(tester) async {
await tester.pumpWidget(wrap(
const Column(
children: [
EinblendItem(index: 12, child: Text('Zwölf')),
EinblendItem(index: 50, child: Text('Fünfzig')),
],
),
));
// Deckel: min(index, 12) * 25ms → beide starten spätestens nach 300ms.
// Ohne Deckel bräuchte Index 50 1250ms Verzögerung und wäre hier noch
// bei Opacity 0.
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(const Duration(milliseconds: 220));
for (final item in find.byType(EinblendItem).evaluate()) {
expect(opacityVon(tester, find.byWidget(item.widget)), 1);
}
});
}