Author SHA1 Message Date
Hermes (Server) 5e76523700 Login-Analyse gesichert: Bug war im Server (case-sensitiv), nicht in der App
Melo wird als Nebenprojekt pausiert — Übergabe-Doku LOGIN_STAND.md hält
den Analysestand fest: App-Code war bereits korrekt (beide früheren
Login-Fixes gemergt, 39 Tests grün), der eigentliche Bug lag in
handle_login() in /home/dustin/scripts/baka_auth.py (case-sensitiver
Username-Vergleich, inkonsistent zum Rest des Systems) und wurde dort
mit Dustins Freigabe behoben. Offener Punkt: echter Gerätetest steht
noch aus.
2026-08-31 17:10:44 +02:00
Hermes (Server) ec9b0d0f29 Merge branch 'feature/youtube-gast-zugang'
# Conflicts:
#	CHANGELOG.md
#	test/downloads/online_screen_test.dart
2026-08-30 10:34:58 +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)andClaude Sonnet 5 c3f541f266 BakaAuth.abmelden() setzt auch Kontingent-Anzeige zurück
Review-Fix: abmelden() setzte _verbleibend bisher nicht zurück,
anders als GastZugang.verwerfen() (_verbleibend = null). Ohne den
Fix bliebe nach einem Account-Wechsel (z.B. Baka -> Tinker) der
alte Kontingent-Stand sichtbar, bis der nächste Download einen
frischen Stand liefert. Aktuell folgenlos (verbleibend ist bis zum
Server-Rollout ohnehin immer null), aber ein latenter Bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
2026-08-29 13:27:44 +02:00
Hermes (Server)andClaude Sonnet 5 d03a4e93a0 Cloud-Account-Kontingent: App liest cloud_remaining, sobald der Server es liefert
BakaAuth bekommt verbleibend/merkeVerbleibend analog zu GastZugang.
YtDownloadService liest cloud_remaining bei Cloud-Zugriff (nicht bei
Gast-Zugriff — Felder bleiben getrennt). UI zeigt "Noch N von 100
heute" für angemeldete Cloud-Accounts. Reine App-Seite, wirkungslos
bis der Server (separates Vorhaben) cloud_remaining liefert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
2026-08-29 13:22:04 +02:00
Hermes (Server)andClaude Sonnet 5 8611738124 Spec-Nachtrag: 100/Tag-Limit auch für Cloud-Accounts (nicht mehr unbegrenzt)
Dustin hat beim Durchsprechen der Proxy-Architektur bestätigt: auch
Dustin/Baka/Tinker bekommen ein Tages-Limit (100 statt 5 bei Gästen),
nicht mehr unbegrenzt. Serverseitiger Vertrag (cloud_quota-Tabelle,
cloud_remaining-Feld) und App-Seite (BakaAuth.verbleibend, UI-Text)
dokumentiert, analog zum bestehenden Gast-Limit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
2026-08-29 13:17:35 +02:00
Hermes (Server)andClaude Sonnet 5 d20f01adee fix: Gast-Zugang-Sperre, veralteter Token, Doppel-Tap, Hinweistext (Branch-Review)
Behebt die Findings aus dem abschließenden Gesamt-Branch-Review für
feature/youtube-gast-zugang, alle in einem Rutsch:

- KRITISCH: Gast-Modus war eine Einbahnstraße — einmal "Als Gast
  fortfahren" getippt, war _AnmeldeDialog (einziger Baka-Login-Einstieg,
  auch für Melo-Cloud-Sync) dauerhaft unerreichbar. Neu:
  GastZugang.verwerfen() plus "Anmelden"-Knopf neben der Gast-Statuszeile
  in beiden Screens.
- WICHTIG: zugriffOk prüfte den rohen gast.hatToken statt istGastModus —
  ein alter Gast-Token konnte den Auto-Login-Rückfallweg für Server-User
  verdecken. istGastModus wird jetzt vor zugriffOk berechnet und dort
  verwendet.
- WICHTIG: CHANGELOG ergänzt — der Server-Endpunkt POST /api/guest-token
  fehlt noch (separates Vorhaben).
- Doppel-Tap-Schutz für den Gast-Button in beiden Screens.
- Widersprüchlicher Hinweistext im YT-Suche-Tab (verlangte Baka-Login
  direkt über dem Gast-Button) korrigiert.
- Zwei Gast-Tests in youtube_search_screen_test.dart bekommen jetzt
  echtes gast:-Wiring in YtSearchService/YtDownloadService.
- 2 neue Regressionstests fürs Anmelden-Escape-Hatch.

Getestet: volle Suite grün (602 Tests, 1 skipped, 0 failed),
flutter analyze ohne Befund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-26 22:17:03 +02:00
Hermes (Server)andClaude Sonnet 5 b32f3198f8 CHANGELOG: YouTube-Gast-Zugang (App-Seite)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-26 21:48:29 +02:00
Hermes (Server)andClaude Sonnet 5 aecb8e3364 YT-Suche-Tab: Gast-Zugang mit bewusster Wahl + Tages-Zähler
Analog zum Download-Tab: echte Gäste sehen zusätzlich zum
bestehenden Hinweis den Button "Als Gast fortfahren (5
Downloads/Tag)"; im Gast-Modus ersetzt ein Zähler den Hinweis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:37:08 +02:00
Hermes (Server)andClaude Sonnet 5 81ec1e232b CHANGELOG: Download-Tab-Gast-Zugang dokumentieren
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:25:25 +02:00
Hermes (Server)andClaude Sonnet 5 cfebcc03b5 Download-Tab: Gast-Zugang mit bewusster Wahl + Tages-Zähler
Echte Gäste sehen jetzt zwei Buttons: "Beim Baka-Konto anmelden"
und "Als Gast fortfahren (5 Downloads/Tag)". Im Gast-Modus wird
der Cookie-Schalter ausgeblendet (Server erzwingt cookies=false
für Gast-Zugriffe ohnehin) und ein Zähler "noch N von 5 heute"
gezeigt, sobald der Server ihn meldet.

_YouTubeBereich.build() beobachtet jetzt app-weit GastZugang; das
Qualitäts-Gate deckte auf, dass hauptmenue_test.dart und
home_shell_test.dart eigene Provider-Bäume ohne GastZugang bauen
(sie spiegeln main.dart nach, statt es zu importieren) — dort
GastZugang ergänzt, analog zum bestehenden Muster für die anderen
Dienste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:24:08 +02:00
Hermes (Server)andClaude Sonnet 5 5347797292 main.dart: GastZugang app-weit verdrahten
Neue Instanz wird beim Start geladen (gespeicherten Gast-Token
wiederherstellen), an YtDownloadService/YtSearchService gereicht
und als Provider bereitgestellt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:07:35 +02:00
Hermes (Server)andClaude Sonnet 5 9e2294a480 YtSearchService: Gast-Zugang neben Baka-Auth unterstützen
Analog zu YtDownloadService: optionaler gast-Parameter, X-Guest-
Token statt Authorization, automatisches Token-Nachholen bei 401.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 20:56:58 +02:00
Hermes (Server)andClaude Sonnet 5 1ff03c96ed YtDownloadService: Gast-Zugang neben Baka-Auth unterstützen
Optionaler gast-Parameter (GastZugang) — sendet bei fehlender
Baka-Anmeldung X-Guest-Token statt Authorization, übernimmt
guest_remaining aus der Erfolgsantwort, holt bei 401 automatisch
einen neuen Gast-Token nach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 20:45:52 +02:00
Hermes (Server)andClaude Sonnet 5 2e14256ded Neuer GastZugang-Service für den anonymen YouTube-Gast-Zugang
Analog zu BakaAuth, aber ohne Passwort: holt/speichert einen
geräte-gebundenen Gast-Token vom neuen /api/guest-token-Endpunkt
(siehe docs/superpowers/specs/2026-08-26-youtube-guest-quota-design.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_$(uuidgen | cut -d- -f1)
2026-08-26 20:33:22 +02:00
36 changed files with 4678 additions and 357 deletions
+183 -21
View File
@@ -5,28 +5,67 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased]
### 🔑 Baka-Konto-Dialog trimmte das Passwort nicht — Autofill-Leerzeichen ließ korrekte Logins scheitern (2026-08-29)
### 🐛 Vollbild-Player Live-Blur-Übergang: 3 Bugfixes aus dem finalen Code-Review (2026-08-29)
- 🐛 **Im "Baka-Konto"-Anmelde-Dialog (`_AnmeldeDialogState._anmelden()` in
`downloads_screen.dart`) wurde der Benutzername vor dem Absenden getrimmt
(`_benutzer.text.trim()`), das Passwort aber nicht** (`_passwort.text`
roh, an beiden Stellen: beim `BakaAuth.anmelden(...)`-Aufruf UND beim
anschließenden `NavidromeService.speichereZugangsdaten(...)`). Ein durch
Autofill/Tastatur-Vorschläge im `obscureText`-Passwortfeld eingefügtes
unsichtbares Leerzeichen landete dadurch im gesendeten UND dauerhaft
gespeicherten Passwort — der Login schlug fehl, obwohl der Nutzer sein
Passwort korrekt kannte.
- 🔧 **Fix:** `_passwort.text.trim()` an beiden Stellen, mit demselben
Kommentar wie beim strukturell identischen, bereits behobenen Fall im
Navidrome-Login-Dialog (`settings_screen.dart`, Commit `3d1e00646ac`) —
das war schon einmal exakt dasselbe Symptom in einem zweiten
Passwort-Dialog. Falls das Muster ein drittes Mal auftaucht: derselbe
Trim-Fix, derselbe Kommentar.
-**Getestet (RED→GREEN):** neuer Widget-Test in `online_screen_test.dart`
gibt im Passwortfeld `'geheim '` (mit Leerzeichen) ein und bestätigt, dass
sowohl der gesendete `POST /auth/login`-Body als auch
`nav.passwort` das getrimmte `'geheim'` enthalten. Volle Suite (605 Tests)
grün, `flutter analyze` ohne Befund.
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)
@@ -197,6 +236,129 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
-**Getestet:** 3 neue Widget-Tests (RED→GREEN, je einer pro Stelle),
volle Suite (590 Tests) grün, `flutter analyze` ohne Befund.
### 🐛 Cloud-Kontingent-Fix aus dem Task-Review (2026-08-29)
- 🔁 **`BakaAuth.abmelden()` setzt jetzt auch `verbleibend` zurück.**
Referenzmuster `GastZugang.verwerfen()` setzt `_verbleibend = null` mit,
`abmelden()` tat das bisher nicht — nur `_token`/`_benutzer` wurden
zurückgesetzt. Meldet sich ein Cloud-User ab und ein ANDERER Cloud-User
(Dustin/Baka/Tinker) danach an, wäre sonst bis zum nächsten Download der
alte Kontingent-Stand des vorherigen Accounts sichtbar geblieben.
Aktuell folgenlos (da `verbleibend` bis zum Server-Rollout ohnehin immer
`null` ist), aber ein latenter Bug, der beim Server-Rollout live
gegangen wäre. (`lib/services/baka_auth.dart`)
-**Getestet (RED→GREEN):** neuer Test „Abmelden setzt auch den
Kontingent-Stand zurück" in `test/services/baka_auth_test.dart`. Volle
Suite grün, `flutter analyze` ohne Befund.
### ☁️ Cloud-Account-Kontingent: App-Seite für das 100/Tag-Limit (2026-08-29)
- ⚠️ **Reine App-Seite, ohne Wirkung bis der Server mitzieht.** Die
Server-Zählung (`cloud_quota`, 100/Tag je Nutzername) in `yt_proxy.py`
ist NICHT Teil dieser Änderung — das macht die `claude-server`-Session
separat (siehe Nachtrag "100/Tag-Limit auch für Cloud-Accounts" in
`docs/superpowers/specs/2026-08-26-youtube-guest-quota-design.md`). Bis
der Server das neue Feld `cloud_remaining` liefert, bleibt
`auth.verbleibend` einfach `null` — kein Fehler, kein sichtbarer
Unterschied zu heute.
- 🔢 **`BakaAuth` bekommt einen Kontingent-Stand** — analog zu
`GastZugang.verbleibend`/`.merkeVerbleibend(n)`: neues Feld `int?
verbleibend` (Getter) und `void merkeVerbleibend(int n)`
(`lib/services/baka_auth.dart`).
- 🔌 **`YtDownloadService.herunterladen()` liest `cloud_remaining`** aus
einer erfolgreichen `POST /api/yt-dl`-Antwort und ruft bei
Cloud-Zugriff (`!gastAktiv`) `auth.merkeVerbleibend(...)` auf — genau
wie beim bestehenden `guest_remaining`/`gast.merkeVerbleibend(...)`
fürs Gast-Kontingent, aber strikt getrennt (ein Gast-Zugriff liest das
Feld nie, ein Cloud-Zugriff nie `guest_remaining`).
(`lib/services/yt_download_service.dart`)
- 📟 **Angemeldete Cloud-Accounts sehen jetzt „Noch N von 100 heute“**,
sobald der Server den Stand gemeldet hat — an vergleichbarer Stelle wie
der bestehende Gast-Zähler, aber nur bei `auth.istAngemeldet` (Gäste
haben weiter ihren eigenen Text, beide schließen sich gegenseitig aus).
(`lib/downloads/downloads_screen.dart`,
`lib/downloads/youtube_search_screen.dart`)
-**Getestet (TDD, RED→GREEN):** `merkeVerbleibend` in
`test/services/baka_auth_test.dart`; Erfolgsfall mit `cloud_remaining`
plus eine Trennschärfe-Gegenprobe (Gast-Zugriff liest `cloud_remaining`
nie, selbst wenn es im Body steht) in
`test/services/yt_download_test.dart`; je ein Widget-Test für den neuen
„Noch N von 100 heute“-Text in `test/downloads/online_screen_test.dart`
und `test/downloads/youtube_search_screen_test.dart`. Volle Suite
grün, `flutter analyze` ohne Befund.
### 🐛 Gast-Zugang: Fixe aus dem Branch-Abschluss-Review (2026-08-26)
- 🔓 **Gast-Modus war eine Einbahnstraße.** Einmal "Als Gast fortfahren"
angetippt, blieb `gast.hatToken` dauerhaft `true` (Token steckt im
Secure Storage, wird bei jedem App-Start wiederhergestellt) — und damit
war `_AnmeldeDialog`, der EINZIGE Einstieg in der App für die
Baka-Anmeldung (auch für den Melo-Cloud-Sync-Login!), unerreichbar.
Neu: `GastZugang.verwerfen()` verwirft den Token wieder, und ein
"Anmelden"-Knopf neben der Gast-Statuszeile (Download-Tab und
YT-Suche-Tab) ruft ihn auf.
- 🔀 **Ein alter Gast-Token hat den Auto-Login-Rückfallweg für
Server-User verdeckt.** `zugriffOk` prüfte bisher den rohen
`gast.hatToken` statt `istGastModus` — ein Gerät, das früher mal als
Gast lief und jetzt als Server-User mit fehlgeschlagenem Auto-Login
dasteht, bekam die Fehlermeldung dazu nie zu sehen und lud still über
den (limitierten) Gast-Pfad. `istGastModus` wird jetzt vor `zugriffOk`
berechnet und dort statt des rohen Tokens verwendet.
(`lib/downloads/downloads_screen.dart`,
`lib/downloads/youtube_search_screen.dart`)
- ⏱️ **Doppel-Tap auf "Als Gast fortfahren" konnte zwei Anfragen
gleichzeitig auslösen.** Beide Screens sperren den Button jetzt während
`holeToken()` läuft (`_gastLaeuft`).
- 📝 **Widersprüchlicher Hinweistext im YT-Suche-Tab behoben** — der Text
über dem Gast-Button verlangte fälschlich eine Baka-Anmeldung.
- ⚠️ **CHANGELOG ergänzt:** der Abschnitt zum Gast-Zugang unten macht jetzt
klar, dass die Server-Seite (`POST /api/guest-token`) noch fehlt.
-**Getestet:** 2 neue Regressionstests fürs "Anmelden"-Escape-Hatch
(`test/downloads/online_screen_test.dart`,
`test/downloads/youtube_search_screen_test.dart`), 2 bestehende Gast-Tests
im YT-Suche-Tab jetzt mit echtem `gast:`-Wiring in
`YtSearchService`/`YtDownloadService`. Volle Suite grün, `flutter
analyze` ohne Befund.
### 📺👤 YouTube-Gast-Zugang: bewusste Wahl + Tages-Zähler (Download-Tab + YT-Suche-Tab) (2026-08-26)
- ⚠️ **Braucht noch die Server-Seite.** Der neue Endpunkt `POST
/api/guest-token` und die Quota-Logik in `yt_proxy.py` existieren noch
nicht (separates Vorhaben, siehe
`docs/superpowers/specs/2026-08-26-youtube-guest-quota-design.md`). Bis
die Server-Seite steht, liefert der "Als Gast fortfahren"-Button
zuverlässig einen 404-Fehler.
- 👤 **Echte Gäste (kein Baka-Konto, kein Melo-Server) sehen jetzt zwei
Buttons** statt nur der Anmeldung: "Beim Baka-Konto anmelden" und neu
"Als Gast fortfahren (5 Downloads/Tag)". Der Gast-Button holt sich per
`GastZugang.holeToken()` (aus Task 1-4 dieser Reihe) einen anonymen
Token vom Proxy und schaltet damit den Downloader frei — ganz ohne
Konto. Schlägt das Holen fehl, erscheint der Fehlertext direkt unter
dem Button.
- 🔢 **Im Gast-Modus zeigt eine Statuszeile "Als Gast unterwegs — noch N
von 5 heute"**, sobald der Server den Kontingent-Stand gemeldet hat
(vorher nur der generische Hinweistext "5 Downloads/Tag"). Der
Cookie-Schalter ("YouTube-Cookies des Servers verwenden") ist im
Gast-Modus ausgeblendet — der Server erzwingt für Gast-Zugriffe ohnehin
`cookies=false`, ein sichtbarer Schalter hätte nur eine Wahl vorgetäuscht,
die es nicht gibt.
- 🐛 **Nebenbefund durchs Qualitäts-Gate:** `_YouTubeBereich.build()`
beobachtet jetzt `GastZugang` app-weit über den Provider-Baum. Zwei
Tests (`hauptmenue_test.dart`, `home_shell_test.dart`) bauen ihren
eigenen Provider-Baum nach, statt `main.dart` zu importieren, und
hatten `GastZugang` dort noch nicht — beide entsprechend ergänzt (analog
zum bestehenden Muster für `BakaAuth`/`YtDownloadService`).
- 📺 **YT-Suche-Tab bekommt denselben Gast-Button und -Zähler** wie der
Download-Tab. Das alte "Baka-Anmeldung (siehe Download-Tab)" Hint für
Gäste ist weg — echte Gäste in `lib/downloads/youtube_search_screen.dart`
sehen jetzt "Als Gast fortfahren (5 Downloads/Tag)" und die Status-Zeile
"Als Gast unterwegs — noch N von 5 heute", genauso wie im Download-Tab.
- ✅ **Getestet:** volle Suite grün (600 Tests), `flutter analyze` ohne
Befund. 3 neue Widget-Tests in
`test/downloads/online_screen_test.dart`: beide Buttons sichtbar,
Gast-Token-Erfolg schaltet das Adressfeld frei, Gast-Token-Fehler wird
angezeigt.
### 📺🔑 YouTube-Tab: Server-User brauchen keine eigene Baka-Anmeldung mehr (2026-08-26)
- 🔑 **Server-User (Dustin, Baka, Tinker) sehen den Baka-Anmelde-Schritt nicht
+111
View File
@@ -0,0 +1,111 @@
# Login-Analyse — Stand 2026-08-30 (Melo pausiert als Nebenprojekt)
Dieses Dokument sichert den Stand der Login-Bug-Analyse, bevor die
Melo-Session geschlossen wird. Melo ist pausiert — das hier ist der
Übergabe-Stand für den nächsten Anlauf.
## Ausgangslage
Auftrag: Auto-Anmeldung + manuelle Baka-Anmeldung schlugen in der App fehl,
obwohl die Server-Seite (baka_auth) laut Login-API-Tests für `baka` und
`tinker` einwandfrei lief. Vorgabe: Server ist nicht das Problem, den Bug in
der APP finden + fixen.
## Ergebnis: Der Bug war NICHT in der App
Vollständige Prüfung des App-Codes ergab keinen Fehler:
- `lib/services/baka_auth.dart`, `lib/services/gast_zugang.dart`,
`lib/downloads/downloads_screen.dart`, `lib/downloads/youtube_search_screen.dart`
gelesen und nachvollzogen — Logik korrekt.
- Beide **früher bereits gefundenen** Login-Bugs sind längst im `main`-Branch
gemergt und aktuell vorhanden:
- Race Condition beim Auto-Login (`_laufenderAutoLogin` in `baka_auth.dart`)
— Commit `609ca37edeb`.
- Manuelle Anmeldung schreibt das neue Passwort dauerhaft in
`NavidromeService` zurück (`_AnmeldeDialogState._anmelden()`) —
Commit `196303145a3`.
- 39 relevante Unit-/Widget-Tests grün (`baka_auth_test.dart`,
`gast_zugang_test.dart`, `online_screen_test.dart`).
- Echter Server-Endpoint `https://baka-net.de/auth/login` direkt per `curl`
getestet (von diesem Server aus, der zugleich `baka-net.de` selbst ist) —
Response-Format entspricht exakt dem, was der App-Code erwartet.
- DNS/TLS/Netzwerk/AndroidManifest: kein Problem gefunden.
## Tatsächlicher Root Cause: Server-Bug (behoben)
`/home/dustin/scripts/baka_auth.py` (**außerhalb des App-Repos**, kein Git,
Produktions-Auth-Server für baka-net.de/auth):
`handle_login()` verglich den Benutzernamen **case-sensitiv**
(`WHERE username=?`), während jede andere Stelle im selben System
(`handle_whoami`, `nd_sync` und `baka_auth_sync` in `sso_sync.py`) bewusst
**case-insensitiv** vergleicht. Die App sendet die Schreibweise aus den
gespeicherten Navidrome-Zugangsdaten — weicht die auch nur in
Groß-/Kleinschreibung von der in `baka_auth.db` gespeicherten Schreibweise ab
(z. B. durch den SSO-Sync von Authentik), meldet der Server "Falscher
Benutzer", obwohl Konto und Passwort stimmen. Das erklärt exakt das Muster:
Server-Tests mit exakt passender Schreibweise liefen grün, echte
App-Logins (Auto- **und** manuelle Anmeldung) schlugen fehl.
Dieser Bug war bereits am 29.08. von einer früheren Session an Hermes
gemeldet worden (siehe Session `618ae854-...`, Deep-Investigation-Agent),
aber nie umgesetzt — die Datei war bis zum Fix unverändert seit dem 20.08.
**Fix (2026-08-30, mit Dustins Freigabe):**
- `handle_login()` in `baka_auth.py`: Abfrage jetzt
`WHERE lower(username)=lower(?)`, konsistent mit dem Rest des Systems.
Kanonischer Username aus der DB-Zeile wird danach für Session/`last_login`
weiterverwendet (Indizes im Ergebnis-Tuple entsprechend angepasst).
- Backup vor der Änderung: `/home/dustin/scripts/baka_auth.py.bak-20260830-221410`.
- `baka-auth.service` neu gestartet (`sudo systemctl restart baka-auth.service`).
- Health-Check (`/auth/health`) und Login-Endpoint (mit unbekanntem User,
liefert weiterhin korrekt "Falscher Benutzer") danach verifiziert.
## Offener Punkt — als Nächstes prüfen
**Die tatsächliche Wirkung des Fixes wurde noch NICHT mit echten
Zugangsdaten auf einem echten Gerät bestätigt.** Direkter Zugriff auf
`baka_auth.db` (sqlite3) war in dieser Session durch den
Berechtigungs-Classifier blockiert — die genaue gespeicherte Schreibweise
für `baka`/`tinker` konnte daher nicht verifiziert werden. Der Fix selbst
ist unabhängig davon korrekt (er wendet nur die im System bereits etablierte
Konvention konsequent auch auf `handle_login` an), aber der praktische Beweis
fehlt noch.
**Nächster Schritt:** Dustin, Baka oder Tinker sollten die App (Auto-Login
beim Start, danach zur Sicherheit auch die manuelle Anmeldung im
"YouTube"-Tab) einmal real testen. Falls es weiterhin fehlschlägt:
1. `journalctl -u baka-auth.service -n 50` auf die konkrete Fehlermeldung
prüfen.
2. Prüfen, ob eine Login-Sperre aktiv ist (5 Fehlversuche → 15 Min. Sperre,
`_login_erlaubt`/`_login_fehler` in `baka_auth.py`) — die frühere
Investigation vermutete, dass wiederholte Auto-Login-Versuche mit falscher
Schreibweise genau das ausgelöst haben könnten.
3. Falls weiterhin ein Mismatch vermutet wird: `sso_sync.py` prüfen, ob
`baka_auth_sync()` beim Anlegen eines neuen Users die vom Aufrufer
übergebene Schreibweise 1:1 übernimmt (`INSERT ... VALUES(?,...)` mit dem
Original-`username`-Parameter) — das ist die einzige Stelle, an der die
in `baka_auth.db` gespeicherte Schreibweise ursprünglich entsteht.
## Nebenbefund — geringes Risiko, nicht angefasst
Der Rate-Limit-Schlüssel für Login-Sperren (`_login_erlaubt`/`_login_fehler`
in `baka_auth.py`) verwendet weiterhin die **rohe, ungenormte** Eingabe
(`body.get('username', '?')`), nicht den kanonischen Namen. Bei
unterschiedlicher Groß-/Kleinschreibung über mehrere Login-Versuche hinweg
zählt das Rate-Limit dadurch potenziell inkonsistent (mehrere Zähler pro
echtem Nutzer statt einem). Kein Sicherheitsproblem, nur Ungenauigkeit —
absichtlich nicht mitgefixt, um die Änderung minimal zu halten.
## Für die nächste Session
- Dieser Fix betrifft **kein App-Repo** — es gibt keinen App-Commit dazu,
nur diese Datei hier als Doku. Die eigentliche Änderung liegt in
`/home/dustin/scripts/baka_auth.py` auf dem Server (kein Git-Tracking).
- Hermes/Server-Seite sollte informiert werden, dass der am 29.08. gemeldete
Bug jetzt behoben ist (falls das nicht schon anderweitig passiert ist).
- Volltext der ursprünglichen Root-Cause-Kette (Race Condition →
Passwort-Rückschreibung → getrennte Passwort-Speicher
Navidrome/baka_auth.db → Case-Sensitivity) steht im Transkript der Session
`618ae854-0a01-4321-af16-a3fef7ecbc7e` (`~/.claude/projects/-home-dustin-mello-dev/`).
File diff suppressed because it is too large Load Diff
@@ -50,17 +50,97 @@ vorbehalten.
| Reset-Zeitpunkt | Kalendertag nach Server-Zeit (Aingrad), kein rollierendes 24h-Fenster |
| Suche fürs Limit | Suche bleibt für Gäste unbegrenzt — nur `POST /api/yt-dl` zählt |
| Cookies für Gäste | Nein — nur Cloud-Accounts bekommen Dustins YouTube-Cookies; der `cookies`-Parameter wird bei Gast-Token-Anfragen ignoriert |
| Limit für Cloud-Accounts | **Nachtrag 2026-08-29:** 100 Downloads/Kalendertag je Nutzer (Dustin/Baka/Tinker je einzeln gezählt) — ersetzt das ursprüngliche „kein Limit". Grund: dieselbe Absicherung wie beim Gast-Limit, nur großzügiger, da Cloud-Accounts vertraute Nutzer mit Cookie-Zugriff sind. |
## Architektur
Drei Zugangsklassen beim Proxy:
1. **Cloud-Account** (`Authorization: Bearer <JWT>`, wie heute) — unbegrenzt,
Cookies automatisch.
1. **Cloud-Account** (`Authorization: Bearer <JWT>`, wie heute) — **100
Downloads/Kalendertag je Nutzer** (Nachtrag 2026-08-29, ersetzt
„unbegrenzt"), Cookies automatisch, Suche unbegrenzt (wie bei Gästen).
2. **Gast** (`X-Guest-Token: <token>`, neu) — 5 Downloads/Kalendertag,
Suche unbegrenzt, keine Cookies.
3. **Kein Zugang** — weder Bearer noch gültiger Gast-Token → 401 wie heute.
## Nachtrag 2026-08-29: 100/Tag-Limit auch für Cloud-Accounts
Dustin hat beim erneuten Durchsprechen der Architektur (nach den
Login-Race-Fixes) bestätigt, dass auch Cloud-Accounts (Dustin, Baka,
Tinker) ein Tages-Limit bekommen sollen — 100 statt der ursprünglich
geplanten 5, aber nicht mehr unbegrenzt. Grund: dieselbe
Missbrauchs-/Kosten-Absicherung wie beim Gast-Limit (YouTube-Rate-Limits,
Serverlast), nur mit großzügigerem Kontingent, weil Cloud-Accounts
vertraute, bekannte Nutzer sind und zusätzlich Cookie-Zugriff haben.
**Übernommene Entscheidungen des Gast-Limits (analog, keine neue
Diskussion nötig):** Reset zum Kalendertag (Server-Zeit), Suche zählt
nicht, nur `POST /api/yt-dl` zählt, Klartext-429-Fehlermeldung ohne
automatischen Retry.
**Unterschied zum Gast-Limit:** Cloud-Accounts zählen **pro Nutzername**
(aus dem verifizierten JWT), nicht pro Gerät/Token — ein Nutzer, der die
App auf zwei Geräten installiert hat, teilt sich also ein gemeinsames
Kontingent (folgerichtig, da derselbe Baka-Account).
### Server-seitige Änderungen (zusätzlich zu den bestehenden Abschnitten oben — Hermes/`claude-server`-Worktree)
- Neue Tabelle (oder Erweiterung von `guest_quota`, falls strukturell
identisch — Entscheidung liegt bei der Server-Session) analog zu
`guest_quota`, aber mit `username` statt `token` als Schlüssel:
```sql
CREATE TABLE IF NOT EXISTS cloud_quota (
username TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0,
count_date TEXT NOT NULL
);
```
- `POST /api/yt-dl` bei Cloud-Zugriff (gültiger Bearer-JWT): dieselbe
Tageswechsel-Reset-Logik wie bei Gästen, aber Schlüssel = Nutzername aus
dem JWT. Bei `count >= 100` → `429` mit
`{"error": "Tages-Limit erreicht (100/Tag) — ab morgen wieder verfügbar", "cooldown": <Sekunden bis Mitternacht>}`.
Erfolgsantwort bekommt zusätzlich `"cloud_remaining": <int>` (nur bei
Cloud-Zugriff, analog zu `guest_remaining` bei Gästen — beide Felder
schließen sich gegenseitig aus, nie beide gleichzeitig gesetzt).
- `GET /api/search` bleibt für Cloud-Accounts unverändert unbegrenzt (wie
für Gäste).
### App-seitige Änderungen (diese Seite implementiere ich)
- `BakaAuth` (`lib/services/baka_auth.dart`) bekommt — analog zu
`GastZugang.verbleibend`/`.merkeVerbleibend(n)` — ein neues Feld
`int? verbleibend` (Getter) und `void merkeVerbleibend(int n)`, das den
zuletzt vom Server gemeldeten Kontingent-Stand hält (nur fürs Anzeigen,
`null` vor dem ersten Download in dieser Session).
- `YtDownloadService.herunterladen()` (`lib/services/yt_download_service.dart`):
liest nach einem erfolgreichen `POST /api/yt-dl` zusätzlich
`daten['cloud_remaining']` aus, und ruft bei `!gastAktiv` (also
Cloud-Zugriff) `auth.merkeVerbleibend(cloudRest)` auf — analog zur
bestehenden `gast!.merkeVerbleibend(gastRest)`-Zeile. Ein `429` läuft
bereits durch den bestehenden generischen `_fehlerText(antwort)`-Pfad
(kein neuer Fehlerfall nötig, der zeigt jeden Server-`error`-Text aus dem
200-fremden Statuscode an — funktioniert für 429 genauso wie für 401
außerhalb des Spezialfalls).
- UI (`_YouTubeBereich` in `lib/downloads/downloads_screen.dart`,
`YoutubeSearchScreen` in `lib/downloads/youtube_search_screen.dart`):
zeigt analog zum bestehenden Gast-Text („Als Gast unterwegs — noch N von
5 heute") jetzt auch für angemeldete Cloud-Accounts einen Text „Noch N
von 100 heute", sobald `auth.verbleibend != null` — platziert an
vergleichbarer Stelle wie der Gast-Text, aber nur sichtbar wenn
`auth.istAngemeldet` (nicht für Gäste, die haben ihren eigenen Text).
### Tests (Nachtrag)
- **App:** `BakaAuth.merkeVerbleibend`, `YtDownloadService` liest
`cloud_remaining` bei Cloud-Zugriff (nicht bei Gast-Zugriff — die beiden
Felder dürfen sich nicht vermischen), Widget-Test für den neuen
„Noch N von 100 heute"-Text in beiden Bildschirmen.
- **Server** (Hermes): analog zu den bestehenden Gast-Tests, plus ein Test,
der sicherstellt, dass Gast- und Cloud-Kontingente unabhängig
voneinander gezählt werden (ein Cloud-Login verbraucht kein
Gast-Kontingent und umgekehrt — sie sind ohnehin durch unterschiedliche
Auth-Wege getrennt, aber das schadet nicht, explizit zu testen).
## Server-seitige Komponenten
**Betrifft `/home/dustin/scripts/yt_proxy.py` und ggf. `auth_common.py` —
@@ -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.
+86 -8
View File
@@ -11,6 +11,7 @@ import '../player/audio_handler.dart';
import '../services/baka_auth.dart';
import '../services/media_store.dart';
import '../services/download_service.dart';
import '../services/gast_zugang.dart';
import '../services/navidrome_service.dart';
import '../services/server_neuheiten.dart';
import '../services/yt_download_service.dart';
@@ -615,6 +616,12 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
/// sichtbar, sonst käme so ein User nie mehr an den Dialog heran.
bool _autoLoginFehlgeschlagen = false;
/// Fehlertext, falls das Holen des Gast-Tokens scheitert.
String? _gastFehler;
/// Verhindert Mehrfach-Anfragen bei schnellem Doppel-Antippen.
bool _gastLaeuft = false;
@override
void initState() {
super.initState();
@@ -686,6 +693,21 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
);
}
Future<void> _alsGastFortfahren() async {
if (_gastLaeuft) return;
final gast = context.read<GastZugang>();
setState(() {
_gastFehler = null;
_gastLaeuft = true;
});
final fehler = await gast.holeToken();
if (!mounted) return;
setState(() {
_gastLaeuft = false;
if (fehler != null) _gastFehler = fehler;
});
}
Future<void> _herunterladen() async {
final dienst = context.read<YtDownloadService>();
final lib = context.read<LibraryService>();
@@ -737,13 +759,17 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
@override
Widget build(BuildContext context) {
final auth = context.watch<BakaAuth>();
final gast = context.watch<GastZugang>();
final dienst = context.watch<YtDownloadService>();
// Server-User gelten sofort als berechtigt — die Anmeldung passiert für
// sie im Hintergrund (_pruefeServerUser). Nur wenn die automatische
// Anmeldung nachweislich fehlgeschlagen ist, bleibt der manuelle Weg
// als Rückfalloption sichtbar — sonst gäbe es keinen Weg mehr zurück.
final zugriffOk =
auth.istAngemeldet || (_serverUser && !_autoLoginFehlgeschlagen);
// Ein Gast-Token zählt ebenfalls als Zugang, nur mit Tages-Limit.
final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken;
final zugriffOk = auth.istAngemeldet ||
(_serverUser && !_autoLoginFehlgeschlagen) ||
istGastModus;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
@@ -770,8 +796,26 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
onPressed: _anmelden,
),
),
] else ...[
if (!_serverUser) ...[
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
height: MeloTheme.minTouchTarget + 4,
child: OutlinedButton.icon(
icon: const Icon(Icons.person_outline),
label: const Text('Als Gast fortfahren (5 Downloads/Tag)',
style: TextStyle(fontSize: 16)),
onPressed: _gastLaeuft ? null : _alsGastFortfahren,
),
),
if (_gastFehler != null) ...[
const SizedBox(height: 8),
Text(_gastFehler!,
style: const TextStyle(color: MeloTheme.red, fontSize: 13)),
],
],
] else ...[
if (!_serverUser && auth.istAngemeldet) ...[
Row(
children: [
const Icon(Icons.verified_user, size: 18, color: MeloTheme.text3),
@@ -788,6 +832,42 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
),
const SizedBox(height: 8),
],
if (auth.istAngemeldet && auth.verbleibend != null) ...[
Row(
children: [
const Icon(Icons.cloud_outlined, size: 18, color: MeloTheme.text3),
const SizedBox(width: 8),
Expanded(
child: Text(
'Noch ${auth.verbleibend} von 100 heute',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
),
),
],
),
const SizedBox(height: 8),
],
if (istGastModus) ...[
Row(
children: [
const Icon(Icons.person_outline, size: 18, color: MeloTheme.text3),
const SizedBox(width: 8),
Expanded(
child: Text(
gast.verbleibend == null
? 'Als Gast unterwegs (5 Downloads/Tag)'
: 'Als Gast unterwegs — noch ${gast.verbleibend} von 5 heute',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
),
),
TextButton(
onPressed: gast.verwerfen,
child: const Text('Anmelden'),
),
],
),
const SizedBox(height: 8),
],
TextField(
controller: _url,
enabled: !dienst.laeuft,
@@ -799,6 +879,7 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
),
const SizedBox(height: 12),
_KategorieFeld(controller: _kategorie, aktiv: !dienst.laeuft),
if (!istGastModus)
SwitchListTile(
dense: true,
contentPadding: EdgeInsets.zero,
@@ -900,10 +981,7 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
_fehler = null;
});
final auth = context.read<BakaAuth>();
// Randständige Leerzeichen entfernen (verdeckte
// Fehlerquelle im obscureText-Feld / bei Autofill).
final passwort = _passwort.text.trim();
final fehler = await auth.anmelden(_benutzer.text.trim(), passwort);
final fehler = await auth.anmelden(_benutzer.text.trim(), _passwort.text);
if (!mounted) return;
if (fehler == null) {
// Navidrome- und Baka-Konto teilen sich die Zugangsdaten (siehe
@@ -912,7 +990,7 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
// Passwort scheitern.
final nav = context.read<DownloadService>().navidrome;
await nav.speichereZugangsdaten(
nav.serverUrl, _benutzer.text.trim(), passwort);
nav.serverUrl, _benutzer.text.trim(), _passwort.text);
if (!mounted) return;
Navigator.pop(context);
return;
+2 -4
View File
@@ -5,7 +5,7 @@ import 'package:provider/provider.dart';
import '../library/database.dart';
import '../library/song_media.dart';
import '../player/audio_handler.dart';
import '../player/now_playing_screen.dart';
import '../player/player_expansion_controller.dart';
import '../services/navidrome_service.dart';
import '../shared/cover.dart';
import '../shared/lauf_balken.dart';
@@ -79,9 +79,7 @@ class _ServerTitelScreenState extends State<ServerTitelScreen> {
try {
final uebersprungen = await handler.loadPlaylist(items, startIndex: ab);
if (!mounted) return;
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
);
context.read<PlayerExpansionController>().open(context);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
+97 -8
View File
@@ -7,6 +7,7 @@ import '../library/database.dart';
import '../library/library_service.dart';
import '../services/baka_auth.dart';
import '../services/download_service.dart';
import '../services/gast_zugang.dart';
import '../services/media_store.dart';
import '../services/yt_download_service.dart';
import '../services/yt_search_service.dart';
@@ -34,6 +35,12 @@ class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
/// Hinweis auf die manuelle Anmeldung (siehe Download-Tab) sichtbar.
bool _autoLoginFehlgeschlagen = false;
/// Fehlertext, falls das Holen des Gast-Tokens scheitert.
String? _gastFehler;
/// Verhindert Mehrfach-Anfragen bei schnellem Doppel-Antippen.
bool _gastLaeuft = false;
@override
void initState() {
super.initState();
@@ -51,6 +58,21 @@ class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
});
}
Future<void> _alsGastFortfahren() async {
if (_gastLaeuft) return;
final gast = context.read<GastZugang>();
setState(() {
_gastFehler = null;
_gastLaeuft = true;
});
final fehler = await gast.holeToken();
if (!mounted) return;
setState(() {
_gastLaeuft = false;
if (fehler != null) _gastFehler = fehler;
});
}
@override
void dispose() {
_query.dispose();
@@ -67,9 +89,12 @@ class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
@override
Widget build(BuildContext context) {
final auth = context.watch<BakaAuth>();
final gast = context.watch<GastZugang>();
final suche = context.watch<YtSearchService>();
final zugriffOk =
auth.istAngemeldet || (_serverUser && !_autoLoginFehlgeschlagen);
final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken;
final zugriffOk = auth.istAngemeldet ||
(_serverUser && !_autoLoginFehlgeschlagen) ||
istGastModus;
return SafeArea(
child: Column(
@@ -97,13 +122,77 @@ class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
],
),
),
if (!zugriffOk)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
if (!zugriffOk) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
'Die Suche läuft über den Baka-Server. Dafür brauchst du '
'deine Baka-Anmeldung (siehe Download-Tab).',
style: TextStyle(color: MeloTheme.text2, fontSize: 13),
_serverUser
? 'Die Suche läuft über den Baka-Server. Dafür brauchst '
'du deine Baka-Anmeldung (siehe Download-Tab).'
: 'Die Suche läuft über den Baka-Server. Melde dich mit '
'deinem Baka-Konto an (siehe Download-Tab) oder '
'nutze sie eingeschränkt als Gast.',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
),
),
if (!_serverUser) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
height: MeloTheme.minTouchTarget + 4,
child: OutlinedButton.icon(
icon: const Icon(Icons.person_outline),
label: const Text('Als Gast fortfahren (5 Downloads/Tag)',
style: TextStyle(fontSize: 16)),
onPressed: _gastLaeuft ? null : _alsGastFortfahren,
),
),
),
if (_gastFehler != null)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(_gastFehler!,
style:
const TextStyle(color: MeloTheme.red, fontSize: 13)),
),
],
],
if (auth.istAngemeldet && auth.verbleibend != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
const Icon(Icons.cloud_outlined, size: 18, color: MeloTheme.text3),
const SizedBox(width: 8),
Expanded(
child: Text(
'Noch ${auth.verbleibend} von 100 heute',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
),
),
],
),
),
if (istGastModus)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Expanded(
child: Text(
gast.verbleibend == null
? 'Als Gast unterwegs (5 Downloads/Tag)'
: 'Als Gast unterwegs — noch ${gast.verbleibend} von 5 heute',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
),
),
TextButton(
onPressed: gast.verwerfen,
child: const Text('Anmelden'),
),
],
),
),
if (suche.laeuft) const LinearProgressIndicator(),
+2 -4
View File
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../player/audio_handler.dart';
import '../player/now_playing_screen.dart';
import '../player/player_expansion_controller.dart';
import '../playlists/create_playlist_dialog.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart';
@@ -384,9 +384,7 @@ class SongZeile extends StatelessWidget {
groupByCategory: settings.groupCoversByCategory),
);
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
);
context.read<PlayerExpansionController>().open(context);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
+196 -12
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
@@ -19,10 +20,13 @@ import 'library/search_screen.dart';
import 'player/audio_effects.dart';
import 'player/audio_handler.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/logger_service.dart';
import 'services/download_service.dart';
import 'services/geteilte_adresse.dart';
import 'services/gast_zugang.dart';
import 'services/melo_cloud_service.dart';
import 'services/offline_mode.dart';
import 'services/navidrome_service.dart';
@@ -41,6 +45,7 @@ late final CategoryService _categories;
late final AppSettings _settings;
late final AudioEffects _effects;
late final BakaAuth _bakaAuth;
late final GastZugang _gastZugang;
late final YtDownloadService _ytDownload;
late final YtSearchService _ytSearch;
late final SyncService _sync;
@@ -76,8 +81,10 @@ Future<void> main() async {
await _effects.init();
_bakaAuth = BakaAuth();
await _bakaAuth.laden();
_ytDownload = YtDownloadService(auth: _bakaAuth);
_ytSearch = YtSearchService(auth: _bakaAuth);
_gastZugang = GastZugang();
await _gastZugang.laden();
_ytDownload = YtDownloadService(auth: _bakaAuth, gast: _gastZugang);
_ytSearch = YtSearchService(auth: _bakaAuth, gast: _gastZugang);
_sync = SyncService(
db: _db,
cloud: MeloCloudService(auth: _bakaAuth),
@@ -112,9 +119,35 @@ Future<void> main() async {
runApp(const MeloApp());
}
class MeloApp extends StatelessWidget {
class MeloApp extends StatefulWidget {
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
Widget build(BuildContext context) {
return MultiProvider(
@@ -128,10 +161,13 @@ class MeloApp extends StatelessWidget {
ChangeNotifierProvider<AppSettings>.value(value: _settings),
ChangeNotifierProvider<AudioEffects>.value(value: _effects),
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
ChangeNotifierProvider<GastZugang>.value(value: _gastZugang),
ChangeNotifierProvider<YtDownloadService>.value(value: _ytDownload),
ChangeNotifierProvider<YtSearchService>.value(value: _ytSearch),
ChangeNotifierProvider<SyncService>.value(value: _sync),
ChangeNotifierProvider<DownloadService>.value(value: _downloads),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: _expansion),
],
child: MaterialApp(
title: 'Melo',
@@ -222,24 +258,136 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
const FavoritesScreen(),
const YoutubeSearchScreen(),
];
return PopScope(
// Tab-Wechsel laufen nur über setState, nie über Navigator.push — die
// Zurück-Geste auf einem Nicht-Start-Tab hätte sonst nichts zum
// Poppen und würde die App minimieren statt zu "Meine Musik" zu gehen.
canPop: _index == 0,
// Nur die Referenz holen, NICHT abonnieren (kein context.watch): die
// 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) setState(() => _index = 0);
if (didPop) return;
if (expansion.progress > 0) {
expansion.close(context);
} else {
setState(() => _index = 0);
}
},
child: child!,
),
child: Scaffold(
body: Column(
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: [
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
// 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(
border: Border(top: BorderSide(color: MeloTheme.border)),
),
@@ -266,6 +414,42 @@ 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;
}
}
+36 -47
View File
@@ -2,10 +2,9 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../shared/cover.dart';
import '../shared/theme.dart';
import 'audio_handler.dart';
import 'now_playing_screen.dart';
import 'player_expansion_controller.dart';
import 'wischen.dart';
/// Kompakte Wiedergabe-Leiste über der Bottom-Nav.
@@ -53,23 +52,20 @@ class _Leiste extends StatefulWidget {
}
class _LeisteState extends State<_Leiste> {
/// Wie weit der Finger die Leiste gerade zieht — nur zur Anzeige, die
/// eigentliche Entscheidung fällt erst bei [onVerticalDragEnd]/
/// [onHorizontalDragEnd]. `dy` bleibt <= 0: nach unten ist unbelegt, ein
/// Mitführen dorthin würde Wirkung vortäuschen, die es nicht gibt.
Offset _versatz = Offset.zero;
bool _ziehtGerade = false;
/// Für die horizontale Titel-Wechsel-Geste (unverändert).
double _horizontalVersatz = 0;
bool _ziehtHorizontalGerade = false;
void _oeffne(BuildContext context) => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
);
/// Rohe, aufsummierte vertikale Zugstrecke seit Gestenbeginn — exakt wie
/// 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() {
setState(() {
_ziehtGerade = false;
_versatz = Offset.zero;
});
}
void _oeffne(BuildContext context) =>
context.read<PlayerExpansionController>().open(context);
@override
Widget build(BuildContext context) {
@@ -88,30 +84,27 @@ class _LeisteState extends State<_Leiste> {
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
// Wischen wie in jeder anderen Musik-App: nach oben öffnet die
// Vollbild-Wiedergabe, seitwärts wechselt den Titel. Die Leiste
// folgt dabei sichtbar dem Finger — sonst blieb ein
// bedächtiges, aber weites Wischen (unter der Schwung-Schwelle)
// 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);
onVerticalDragStart: (_) => _vertikaleStrecke = 0,
onVerticalDragUpdate: (details) {
_vertikaleStrecke += details.delta.dy;
context.read<PlayerExpansionController>().dragBy(details.delta.dy);
},
onVerticalDragEnd: (details) => context
.read<PlayerExpansionController>()
.dragEnd(context, _vertikaleStrecke,
details.velocity.pixelsPerSecond.dy,
ausVollbild: false),
onHorizontalDragUpdate: (details) => setState(() {
_ziehtGerade = true;
_versatz = Offset(_versatz.dx + details.delta.dx, _versatz.dy);
_ziehtHorizontalGerade = true;
_horizontalVersatz += details.delta.dx;
}),
onHorizontalDragEnd: (details) {
final wechsel = titelWechselMitStrecke(
_versatz.dx, details.velocity.pixelsPerSecond.dx);
_zurueckschnappen();
_horizontalVersatz, details.velocity.pixelsPerSecond.dx);
setState(() {
_ziehtHorizontalGerade = false;
_horizontalVersatz = 0;
});
switch (wechsel) {
case Wechsel.naechster:
handler.skipToNext();
@@ -124,24 +117,20 @@ class _LeisteState extends State<_Leiste> {
child: InkWell(
onTap: () => _oeffne(context),
child: AnimatedContainer(
// Während des Ziehens sofort folgen (keine Verzögerung);
// beim Loslassen ohne Wirkung sichtbar zurückschnappen.
duration: _ziehtGerade
duration: _ziehtHorizontalGerade
? Duration.zero
: MeloMotion.ruhig(context, MeloMotion.normal),
curve: MeloMotion.curve,
transform:
Matrix4.translationValues(_versatz.dx, _versatz.dy, 0),
transform: Matrix4.translationValues(_horizontalVersatz, 0, 0),
height: MiniPlayer.hoehe,
child: Row(
children: [
const SizedBox(width: MeloSpace.md),
// Dasselbe Bild wie im Vollbild: es wandert beim Öffnen
// dorthin, statt dass ein neuer Bildschirm aufspringt.
Hero(
tag: coverHeldenName,
child: CoverImage(artUri: item.artUri, size: 52),
),
// Das Cover rendert WanderndesCover in HomeShell in
// einer eigenen Ebene über allem (deckt exakt diese
// Position bei progress == 0 ab) — hier nur Platz
// freihalten, damit Titel/Bedienung nicht springen.
const SizedBox(width: 52),
const SizedBox(width: MeloSpace.md),
Expanded(
child: Column(
+213 -109
View File
@@ -1,7 +1,10 @@
import 'dart:async';
import 'dart:ui' show lerpDouble;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show OverflowBoxFit;
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../library/database.dart';
@@ -13,10 +16,11 @@ import '../shared/theme.dart';
import 'audio_handler.dart';
import 'cover_farbe.dart';
import 'lrc.dart';
import 'player_expansion_controller.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 {
const NowPlayingScreen({super.key});
@@ -31,32 +35,225 @@ class NowPlayingScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
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,
child: Scaffold(
// Der Grund kommt aus dem Coverbild — deshalb hier durchsichtig.
backgroundColor: Colors.transparent,
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
actions: [
child: Material(
color: Colors.transparent,
child: SafeArea(
child: Column(
children: [
_VollbildLeiste(handler: handler, onLyrics: _showLyrics),
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?>(
stream: handler.mediaItem,
builder: (context, snapshot) {
final item = snapshot.data;
if (item == null) return const SizedBox.shrink();
// Bei Server-Titeln die Navidrome-ID, nicht die Abspiel-Adresse:
// mit der fragte die App den Server nach einem Titel namens
// "https://…" und bekam nie einen Text.
final songId = songIdOf(item) ?? navidromeIdOf(item) ?? '';
return IconButton(
tooltip: songId.isEmpty
? 'Kein Songtext für diesen Titel verfügbar'
: 'Songtext',
icon: const Icon(Icons.lyrics),
onPressed: songId.isEmpty
? null
: () => _showLyrics(context, songId),
onPressed: songId.isEmpty ? null : () => onLyrics(context, songId),
);
},
),
@@ -70,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,
),
),
],
),
);
},
);
},
),
),
),
),
);
}
}
@@ -289,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 {
const _ProgressBar({required this.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();
}
}
+2 -4
View File
@@ -7,7 +7,7 @@ import '../library/database.dart';
import '../library/playlist_service.dart';
import '../library/song_media.dart';
import '../player/audio_handler.dart';
import '../player/now_playing_screen.dart';
import '../player/player_expansion_controller.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart';
import '../shared/lauf_balken.dart';
@@ -135,9 +135,7 @@ class _Zeile extends StatelessWidget {
groupByCategory: settings.groupCoversByCategory),
);
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
);
context.read<PlayerExpansionController>().open(context);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
+13
View File
@@ -47,6 +47,7 @@ class BakaAuth extends ChangeNotifier {
String? _token;
String _benutzer = '';
int? _verbleibend;
/// Laufender Login-Versuch aus [autoAnmelden] — solange er offen ist,
/// hängt sich ein überlappender Aufruf daran statt einen zweiten
@@ -61,6 +62,11 @@ class BakaAuth extends ChangeNotifier {
bool get istAngemeldet => _token != null && _token!.isNotEmpty;
String get benutzer => _benutzer;
/// Zuletzt vom Server gemeldeter Kontingent-Stand (100/Tag) — nur fürs
/// Anzeigen, die Wahrheit bleibt beim Server. `null` vor dem ersten
/// Download in dieser Session.
int? get verbleibend => _verbleibend;
/// Header für Anfragen an den Proxy. Ohne Anmeldung ohne `Authorization`.
Map<String, String> get authHeader => {
if (istAngemeldet) 'Authorization': 'Bearer $_token',
@@ -131,9 +137,16 @@ class BakaAuth extends ChangeNotifier {
}
}
/// Übernimmt den zuletzt vom Server gemeldeten Kontingent-Stand.
void merkeVerbleibend(int n) {
_verbleibend = n;
notifyListeners();
}
Future<void> abmelden() async {
_token = null;
_benutzer = '';
_verbleibend = null;
await _speicher.loeschen(_tokenKey);
await _speicher.loeschen(_userKey);
notifyListeners();
+93
View File
@@ -0,0 +1,93 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'baka_auth.dart' show TokenSpeicher, SicherenSpeicher;
import 'yt_download_service.dart';
/// Anonymer Gast-Zugang zum YouTube-Proxy: kein Passwort, aber mit
/// serverseitig durchgesetztem Tages-Limit (der Proxy zählt und meldet den
/// Stand über `guest_remaining` in der yt-dl-Antwort zurück). Ergänzt
/// [BakaAuth]/`baka_auth.dart`, ersetzt es nicht.
class GastZugang extends ChangeNotifier {
GastZugang({http.Client? client, TokenSpeicher? speicher})
: _client = client ?? http.Client(),
_speicher = speicher ?? const SicherenSpeicher();
static const proxyUrl = YtDownloadService.proxyUrl;
static const _tokenKey = 'guest_token';
final http.Client _client;
final TokenSpeicher _speicher;
String? _token;
int? _verbleibend;
bool get hatToken => _token != null && _token!.isNotEmpty;
/// Zuletzt vom Server gemeldeter Kontingent-Stand — nur fürs Anzeigen,
/// die Wahrheit bleibt beim Server. `null` vor dem ersten Download.
int? get verbleibend => _verbleibend;
/// Header für Anfragen an den Proxy. Ohne Token leer.
Map<String, String> get gastHeader =>
{if (hatToken) 'X-Guest-Token': _token!};
/// Stellt einen früher geholten Gast-Token wieder her (App-Start).
Future<void> laden() async {
_token = await _speicher.lesen(_tokenKey);
notifyListeners();
}
/// Holt einen neuen Gast-Token vom Proxy. Gibt `null` bei Erfolg zurück,
/// sonst den Fehlertext.
Future<String?> holeToken() async {
final http.Response antwort;
try {
antwort = await _client
.post(Uri.parse('$proxyUrl/api/guest-token'))
.timeout(const Duration(seconds: 15));
} catch (e) {
debugPrint('Gast-Token nicht erreichbar: $e');
return 'Proxy nicht erreichbar';
}
if (antwort.statusCode != 200) {
return 'Gast-Zugang fehlgeschlagen (${antwort.statusCode})';
}
Map<String, dynamic> daten;
try {
daten = jsonDecode(antwort.body) as Map<String, dynamic>;
} catch (_) {
return 'Proxy antwortet unverständlich (${antwort.statusCode})';
}
final token = daten['guest_token'] as String?;
if (token == null || token.isEmpty) {
return 'Proxy hat keinen Gast-Token geliefert';
}
_token = token;
await _speicher.schreiben(_tokenKey, token);
notifyListeners();
return null;
}
/// Übernimmt den zuletzt vom Server gemeldeten Kontingent-Stand.
void merkeVerbleibend(int n) {
_verbleibend = n;
notifyListeners();
}
/// Verwirft den Gast-Zugang wieder — nötig, damit ein Gast doch noch zum
/// Baka-Konto wechseln kann. Ohne das wäre `_AnmeldeDialog` (der einzige
/// Einstieg in der App für Baka-Login, auch für den Melo-Cloud-Sync)
/// dauerhaft unerreichbar, sobald einmal ein Gast-Token besteht.
Future<void> verwerfen() async {
_token = null;
_verbleibend = null;
await _speicher.loeschen(_tokenKey);
notifyListeners();
}
}
+20 -5
View File
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'baka_auth.dart';
import 'gast_zugang.dart';
/// Entfernt alles, was in einem Dateinamen Ärger macht.
String sichererDateiname(String titel) =>
@@ -35,12 +36,13 @@ class YtErgebnis {
/// Lädt Musik über den Baka-YouTube-Proxy herunter. Der Proxy erledigt die
/// eigentliche Arbeit und liefert eine fertige MP3; die App speichert sie nur.
class YtDownloadService extends ChangeNotifier {
YtDownloadService({required this.auth, http.Client? client})
YtDownloadService({required this.auth, this.gast, http.Client? client})
: _client = client ?? http.Client();
static const proxyUrl = 'https://yt.baka-net.de';
final BakaAuth auth;
final GastZugang? gast;
final http.Client _client;
bool _laeuft = false;
@@ -95,7 +97,8 @@ class YtDownloadService extends ChangeNotifier {
final abbruch = _abbruch = Completer<void>();
notifyListeners();
if (!auth.istAngemeldet) {
final gastAktiv = !auth.istAngemeldet && (gast?.hatToken ?? false);
if (!auth.istAngemeldet && !gastAktiv) {
_scheitere('Bitte zuerst beim Baka-Konto anmelden');
return null;
}
@@ -103,6 +106,7 @@ class YtDownloadService extends ChangeNotifier {
_scheitere('Das ist keine YouTube-Adresse');
return null;
}
final zugangsHeader = gastAktiv ? gast!.gastHeader : auth.authHeader;
// ─── 1/2: Proxy fragen — er lädt das Video und wandelt es um ───
_melde('Proxy wird gefragt …');
@@ -113,7 +117,7 @@ class YtDownloadService extends ChangeNotifier {
.post(
Uri.parse('$proxyUrl/api/yt-dl'),
headers: {
...auth.authHeader,
...zugangsHeader,
'Content-Type': 'application/json'
},
body: jsonEncode({'url': url, 'cookies': cookies}),
@@ -137,7 +141,10 @@ class YtDownloadService extends ChangeNotifier {
}
if (antwort.statusCode == 401) {
_scheitere('Anmeldung abgelaufen — bitte neu anmelden');
if (gastAktiv) unawaited(gast!.holeToken());
_scheitere(gastAktiv
? 'Gast-Zugang abgelaufen — bitte erneut versuchen'
: 'Anmeldung abgelaufen — bitte neu anmelden');
return null;
}
if (antwort.statusCode != 200) {
@@ -149,6 +156,14 @@ class YtDownloadService extends ChangeNotifier {
final titel = daten['titel'] as String? ?? 'Unbekannt';
final dauer = (daten['dauer'] as int?) ?? 0;
final mp3Url = daten['mp3_url'] as String? ?? '/api/dl/audio.mp3';
final gastRest = daten['guest_remaining'] as int?;
if (gastAktiv && gastRest != null) {
gast!.merkeVerbleibend(gastRest);
}
final cloudRest = daten['cloud_remaining'] as int?;
if (!gastAktiv && cloudRest != null) {
auth.merkeVerbleibend(cloudRest);
}
// ─── 2/2: fertige MP3 abholen und speichern ───
_melde('Lade „$titel“ …');
@@ -161,7 +176,7 @@ class YtDownloadService extends ChangeNotifier {
try {
final mp3 = await _abbrechbar(
_client
.get(Uri.parse('$proxyUrl$mp3Url'), headers: auth.authHeader)
.get(Uri.parse('$proxyUrl$mp3Url'), headers: zugangsHeader)
.timeout(const Duration(seconds: 180)),
abbruch,
);
+10 -4
View File
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'baka_auth.dart';
import 'gast_zugang.dart';
import 'yt_download_service.dart';
/// Ein Treffer der YouTube-Suche über den Baka-Proxy.
@@ -44,12 +45,13 @@ String? youtubeVideoId(String url) {
/// Durchsucht YouTube über den Baka-Proxy (`/api/search`, yt-dlp-gestützt) —
/// dieselbe Anmeldung wie [YtDownloadService].
class YtSearchService extends ChangeNotifier {
YtSearchService({required this.auth, http.Client? client})
YtSearchService({required this.auth, this.gast, http.Client? client})
: _client = client ?? http.Client();
static const proxyUrl = YtDownloadService.proxyUrl;
final BakaAuth auth;
final GastZugang? gast;
final http.Client _client;
bool _laeuft = false;
@@ -64,7 +66,8 @@ class YtSearchService extends ChangeNotifier {
final q = query.trim();
if (q.isEmpty) return;
if (!auth.istAngemeldet) {
final gastAktiv = !auth.istAngemeldet && (gast?.hatToken ?? false);
if (!auth.istAngemeldet && !gastAktiv) {
_treffer = const [];
_fehler = 'Bitte zuerst beim Baka-Konto anmelden';
notifyListeners();
@@ -80,7 +83,7 @@ class YtSearchService extends ChangeNotifier {
final http.Response antwort;
try {
antwort = await _client
.get(uri, headers: auth.authHeader)
.get(uri, headers: gastAktiv ? gast!.gastHeader : auth.authHeader)
.timeout(const Duration(seconds: 20));
} on TimeoutException {
_scheitere('Die Suche braucht zu lange — bitte später erneut versuchen');
@@ -92,7 +95,10 @@ class YtSearchService extends ChangeNotifier {
}
if (antwort.statusCode == 401) {
_scheitere('Anmeldung abgelaufen — bitte neu anmelden');
if (gastAktiv) unawaited(gast!.holeToken());
_scheitere(gastAktiv
? 'Gast-Zugang abgelaufen — bitte erneut versuchen'
: 'Anmeldung abgelaufen — bitte neu anmelden');
return;
}
if (antwort.statusCode != 200) {
+101 -28
View File
@@ -11,6 +11,7 @@ import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/gast_zugang.dart';
import 'package:melo/services/navidrome_service.dart';
import 'package:melo/services/yt_download_service.dart';
import 'package:provider/provider.dart';
@@ -29,7 +30,9 @@ class _MemorySpeicher implements TokenSpeicher {
Widget _wrap(BakaAuth auth,
{required MeloDb db,
required CategoryService categories,
NavidromeService? navidrome}) {
NavidromeService? navidrome,
GastZugang? gast}) {
final gastZugang = gast ?? GastZugang();
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
@@ -38,9 +41,11 @@ Widget _wrap(BakaAuth auth,
// Aufräum-Timer von drift im Test-Rahmen hängen.
ChangeNotifierProvider<CategoryService>.value(value: categories),
ChangeNotifierProvider<BakaAuth>.value(value: auth),
ChangeNotifierProvider<GastZugang>.value(value: gastZugang),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(
auth: auth,
gast: gastZugang,
client: MockClient((_) async => http.Response('', 500)),
),
),
@@ -302,37 +307,18 @@ void main() {
});
testWidgets(
'Anmelden trimmt das Passwort — ein unsichtbares Leerzeichen durch '
'Autofill im obscureText-Feld darf den Login nicht scheitern lassen '
'(wie im Navidrome-Dialog, settings_screen.dart)', (tester) async {
late String gesendetesPasswort;
final auth = BakaAuth(
client: MockClient((request) async {
final body = jsonDecode(request.body) as Map<String, dynamic>;
gesendetesPasswort = body['password'] as String;
if (body['password'] == 'geheim') {
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');
'Angemeldeter Cloud-Account sieht das Kontingent nach einem Download',
(tester) async {
final auth = await _angemeldet();
await tester.pumpWidget(_wrap(auth,
db: db, categories: categories, navidrome: nav));
await tester.pumpWidget(_wrap(auth, db: db, categories: categories));
await tester.pumpAndSettle();
expect(find.textContaining('von 100 heute'), findsNothing);
await tester.tap(find.text('Beim Baka-Konto anmelden'));
await tester.pumpAndSettle();
auth.merkeVerbleibend(63);
await tester.pump();
await tester.enterText(find.byType(TextField).last, 'geheim ');
await tester.tap(find.text('Anmelden'));
await tester.pumpAndSettle();
expect(gesendetesPasswort, 'geheim');
expect(nav.passwort, 'geheim');
expect(find.text('Noch 63 von 100 heute'), findsOneWidget);
});
testWidgets(
@@ -357,4 +343,91 @@ void main() {
expect(gesendeterBenutzer, 'Tinker');
expect(auth.istAngemeldet, isTrue);
});
testWidgets('Gast sieht zwei Buttons: Anmelden und Als Gast fortfahren',
(tester) async {
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
await tester.pumpWidget(_wrap(auth, db: db, categories: categories));
await tester.pumpAndSettle();
expect(find.text('Beim Baka-Konto anmelden'), findsOneWidget);
expect(
find.text('Als Gast fortfahren (5 Downloads/Tag)'), findsOneWidget);
expect(find.text('YouTube-Adresse'), findsNothing);
});
testWidgets(
'Als Gast fortfahren holt einen Token und schaltet das Adressfeld frei',
(tester) async {
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final gast = GastZugang(
client: MockClient((_) async =>
http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
speicher: _MemorySpeicher(),
);
await tester.pumpWidget(
_wrap(auth, db: db, categories: categories, gast: gast));
await tester.pumpAndSettle();
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
await tester.pumpAndSettle();
expect(find.text('YouTube-Adresse'), findsOneWidget);
expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget);
expect(find.text('YouTube-Cookies des Servers verwenden'), findsNothing);
});
testWidgets('Fehler beim Gast-Token wird angezeigt', (tester) async {
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher(),
);
await tester.pumpWidget(
_wrap(auth, db: db, categories: categories, gast: gast));
await tester.pumpAndSettle();
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
await tester.pumpAndSettle();
expect(find.text('Gast-Zugang fehlgeschlagen (500)'), findsOneWidget);
expect(find.text('YouTube-Adresse'), findsNothing);
});
testWidgets(
'Anmelden im Gast-Modus verwirft den Gast-Token und zeigt wieder die '
'Wahl', (tester) async {
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final gast = GastZugang(
client: MockClient((_) async =>
http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
speicher: _MemorySpeicher(),
);
await tester.pumpWidget(
_wrap(auth, db: db, categories: categories, gast: gast));
await tester.pumpAndSettle();
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
await tester.pumpAndSettle();
expect(find.text('YouTube-Adresse'), findsOneWidget);
await tester.tap(find.text('Anmelden'));
await tester.pumpAndSettle();
expect(find.text('Beim Baka-Konto anmelden'), findsOneWidget);
expect(find.text('YouTube-Adresse'), findsNothing);
expect(gast.hatToken, isFalse);
});
}
@@ -9,7 +9,7 @@ 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/now_playing_screen.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:
@@ -57,10 +57,13 @@ void main() {
handler.dispose();
});
Widget wrap(Widget child) => MultiProvider(
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),
);
@@ -68,21 +71,26 @@ void main() {
testWidgets('Titel antippen öffnet den Vollbild-Player', (tester) async {
final navidrome = NavidromeService()
..setCredentials('https://musik.baka-net.de', 'Baka', 'geheim');
await tester.pumpWidget(wrap(ServerTitelScreen(
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(find.byType(NowPlayingScreen), findsNothing);
expect(expansion.progress, 0.0);
await tester.tap(find.text('Feuer'));
await tester.pumpAndSettle();
expect(find.byType(NowPlayingScreen), findsOneWidget);
expect(expansion.progress, 1.0);
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo beim Abbau des
// Baums als offener Fake-Timer stehen.
+106 -1
View File
@@ -11,6 +11,7 @@ import 'package:melo/library/database.dart';
import 'package:melo/library/library_service.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/gast_zugang.dart';
import 'package:melo/services/navidrome_service.dart';
import 'package:melo/services/yt_download_service.dart';
import 'package:melo/services/yt_search_service.dart';
@@ -43,12 +44,14 @@ Widget _wrap({
required YtSearchService search,
required YtDownloadService download,
NavidromeService? navidrome,
GastZugang? gast,
}) {
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: LibraryService(db)),
ChangeNotifierProvider<BakaAuth>.value(value: auth),
ChangeNotifierProvider<GastZugang>.value(value: gast ?? GastZugang()),
ChangeNotifierProvider<YtSearchService>.value(value: search),
ChangeNotifierProvider<YtDownloadService>.value(value: download),
// Nur für die Server-User-Prüfung gebraucht (siehe
@@ -89,7 +92,7 @@ void main() {
db: db, auth: auth, search: search, download: download));
await tester.pumpAndSettle();
expect(find.textContaining('Baka-Anmeldung'), findsOneWidget);
expect(find.textContaining('Baka-Server'), findsOneWidget);
expect(find.text('Nach Musikvideos suchen'), findsOneWidget);
await db.close();
@@ -173,6 +176,26 @@ void main() {
await db.close();
});
testWidgets('Angemeldeter Cloud-Account sieht das Kontingent nach Download',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final auth = await _angemeldeteAuth();
final search = YtSearchService(auth: auth);
final download = YtDownloadService(auth: auth);
await tester.pumpWidget(_wrap(
db: db, auth: auth, search: search, download: download));
await tester.pumpAndSettle();
expect(find.textContaining('von 100 heute'), findsNothing);
auth.merkeVerbleibend(98);
await tester.pump();
expect(find.text('Noch 98 von 100 heute'), findsOneWidget);
await db.close();
});
// Kein Widget-Test für den Download-Knopf selbst: YtDownloadService.
// herunterladen() kombiniert Future.any mit .timeout() — dessen Timer
// bleibt unter Flutters fake_async-Testbindung immer als "pending" stehen
@@ -182,4 +205,86 @@ void main() {
// _YouTubeBereich (downloads_screen.dart) keinen eigenen Widget-Test —
// die Download-Logik selbst ist über yt_download_test.dart ausführlich
// abgedeckt.
testWidgets('Gast sieht den Button "Als Gast fortfahren"', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher(),
);
final search = YtSearchService(auth: auth, gast: gast);
final download = YtDownloadService(auth: auth, gast: gast);
await tester.pumpWidget(_wrap(
db: db, auth: auth, search: search, download: download, gast: gast));
await tester.pumpAndSettle();
expect(
find.text('Als Gast fortfahren (5 Downloads/Tag)'), findsOneWidget);
await db.close();
});
testWidgets(
'Als Gast fortfahren blendet den Baka-Hinweis aus und zeigt den Zähler',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final gast = GastZugang(
client: MockClient((_) async =>
http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
speicher: _MemorySpeicher(),
);
final search = YtSearchService(auth: auth, gast: gast);
final download = YtDownloadService(auth: auth, gast: gast);
await tester.pumpWidget(_wrap(
db: db, auth: auth, search: search, download: download, gast: gast));
await tester.pumpAndSettle();
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
await tester.pumpAndSettle();
expect(find.textContaining('Baka-Anmeldung'), findsNothing);
expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget);
await db.close();
});
testWidgets(
'Anmelden im Gast-Modus verwirft den Gast-Token und zeigt wieder den '
'Hinweis', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final search = YtSearchService(auth: auth);
final download = YtDownloadService(auth: auth);
final gast = GastZugang(
client: MockClient((_) async =>
http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
speicher: _MemorySpeicher(),
);
await tester.pumpWidget(_wrap(
db: db, auth: auth, search: search, download: download, gast: gast));
await tester.pumpAndSettle();
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
await tester.pumpAndSettle();
expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget);
await tester.tap(find.text('Anmelden'));
await tester.pumpAndSettle();
expect(find.textContaining('Baka-Server'), findsOneWidget);
expect(gast.hatToken, isFalse);
await db.close();
});
}
+21 -7
View File
@@ -7,8 +7,10 @@ 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/player_expansion_controller.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/gast_zugang.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/navidrome_service.dart';
import 'package:melo/services/offline_mode.dart';
@@ -51,7 +53,9 @@ void main() {
late YtSearchService ytSearch;
late DownloadService downloads;
late SyncService sync;
late GastZugang gast;
MeloAudioHandler? handler;
PlayerExpansionController? expansion;
setUp(() {
SharedPreferences.setMockInitialValues({});
@@ -62,8 +66,9 @@ void main() {
auth = BakaAuth(speicher: _MemorySpeicher());
einstellungen = AppSettings();
offline = OfflineMode();
ytDownload = YtDownloadService(auth: auth);
ytSearch = YtSearchService(auth: auth);
gast = GastZugang();
ytDownload = YtDownloadService(auth: auth, gast: gast);
ytSearch = YtSearchService(auth: auth, gast: gast);
downloads = DownloadService(db: db, navidrome: NavidromeService());
sync = SyncService(db: db, cloud: MeloCloudService(auth: auth));
});
@@ -81,10 +86,14 @@ void main() {
// 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()
// 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
// Plattform-Kanal zu, den es im setUp noch nicht gibt.
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(
providers: [
Provider<MeloDb>.value(value: db),
@@ -95,10 +104,13 @@ void main() {
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<OfflineMode>.value(value: offline),
ChangeNotifierProvider<BakaAuth>.value(value: auth),
ChangeNotifierProvider<GastZugang>.value(value: gast),
ChangeNotifierProvider<YtDownloadService>.value(value: ytDownload),
ChangeNotifierProvider<YtSearchService>.value(value: ytSearch),
ChangeNotifierProvider<DownloadService>.value(value: downloads),
ChangeNotifierProvider<SyncService>.value(value: sync),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion!),
],
child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()),
);
@@ -113,6 +125,8 @@ void main() {
await tester.pumpWidget(const SizedBox());
handler?.dispose();
handler = null;
expansion?.dispose();
expansion = null;
// 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
// pump() ohne Dauer lässt sie liegen, und der Test-Rahmen meldet dann
@@ -126,7 +140,7 @@ void main() {
);
testWidgets('alle vier Bereiche stehen im Hauptmenü', (tester) async {
await tester.pumpWidget(bauen());
await tester.pumpWidget(bauen(tester));
await tester.pump();
final leiste =
@@ -138,7 +152,7 @@ void main() {
testWidgets('jeder Bereich ist beschriftet und liegt auf dem Bildschirm',
(tester) async {
await tester.pumpWidget(bauen());
await tester.pumpWidget(bauen(tester));
await tester.pump();
final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio;
@@ -163,7 +177,7 @@ void main() {
final ziel = i;
testWidgets('Tipp auf "${hauptmenue[ziel]}" wechselt dorthin',
(tester) async {
await tester.pumpWidget(bauen());
await tester.pumpWidget(bauen(tester));
await tester.pump();
await tester.tap(eintrag(hauptmenue[ziel]));
@@ -182,7 +196,7 @@ void main() {
testWidgets('das Hauptmenü klebt am unteren Rand', (tester) async {
// Läge es woanders, wäre es kein Hauptmenü mehr.
await tester.pumpWidget(bauen());
await tester.pumpWidget(bauen(tester));
await tester.pump();
final menue = tester.getRect(find.byType(BottomNavigationBar));
+12
View File
@@ -7,8 +7,10 @@ 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/player_expansion_controller.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/gast_zugang.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/navidrome_service.dart';
import 'package:melo/services/offline_mode.dart';
@@ -39,6 +41,7 @@ void main() {
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider(
providers: [
@@ -50,6 +53,7 @@ void main() {
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<GastZugang>(create: (_) => GastZugang()),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<YtSearchService>(
@@ -59,6 +63,8 @@ void main() {
ChangeNotifierProvider<SyncService>(
create: (_) => SyncService(
db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
@@ -70,6 +76,7 @@ void main() {
['Meine Musik', 'Suchen', 'Download', 'Favoriten', 'YT-Suche']);
handler.dispose();
expansion.dispose();
await db.close();
});
@@ -80,6 +87,7 @@ void main() {
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider(
providers: [
@@ -91,6 +99,7 @@ void main() {
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<GastZugang>(create: (_) => GastZugang()),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<YtSearchService>(
@@ -100,6 +109,8 @@ void main() {
ChangeNotifierProvider<SyncService>(
create: (_) => SyncService(
db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
@@ -137,6 +148,7 @@ void main() {
// einem tester.tap.
await tester.pumpWidget(const SizedBox());
handler.dispose();
expansion.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
}
+7 -3
View File
@@ -11,7 +11,7 @@ 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';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff:
@@ -91,6 +91,8 @@ void main() {
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: [
@@ -99,6 +101,8 @@ void main() {
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: MaterialApp(
home: Scaffold(
@@ -112,12 +116,12 @@ void main() {
),
);
expect(find.byType(NowPlayingScreen), findsNothing);
expect(expansion.progress, 0.0);
await tester.tap(find.text('Roses'));
await tester.pumpAndSettle();
expect(find.byType(NowPlayingScreen), findsOneWidget);
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
+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,184 @@
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/gast_zugang.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<GastZugang>(create: (_) => GastZugang()),
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<GastZugang>(create: (_) => GastZugang()),
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));
});
}
+57 -14
View File
@@ -12,6 +12,7 @@ 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';
@@ -88,20 +89,30 @@ void main() {
metadataEdited: false,
);
// Öffnet den Vollbild-Player über denselben Weg wie in
// Startet die Wiedergabe über denselben Weg wie in
// song_list_navigation_test.dart (Tap auf eine SongZeile) — dieser Weg ist
// dort nachweislich unproblematisch, im Unterschied zu einem direkten
// Aufbau von NowPlayingScreen mit vorab befülltem MediaItem-Stream.
Future<void> oeffnePlayer(WidgetTester tester) async {
await tester.pumpWidget(
MultiProvider(
providers: [
// 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(
@@ -116,6 +127,18 @@ void main() {
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 {
@@ -133,12 +156,11 @@ void main() {
expect(find.byType(NowPlayingScreen), findsOneWidget);
// Nicht mehr in der AppBar:
final herzInAppBar = find.descendant(
of: find.byType(AppBar),
matching: find.byType(FavoriteButton),
);
expect(herzInAppBar, findsNothing);
// 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
@@ -152,4 +174,25 @@ void main() {
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,152 @@
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/gast_zugang.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<GastZugang>(create: (_) => GastZugang()),
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,
),
);
});
}
@@ -10,7 +10,7 @@ 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/now_playing_screen.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/playlists/playlist_detail_screen.dart';
import 'package:melo/settings/app_settings.dart';
@@ -90,6 +90,8 @@ void main() {
deleted: false,
);
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
@@ -98,6 +100,8 @@ void main() {
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),
@@ -106,12 +110,12 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.byType(NowPlayingScreen), findsNothing);
expect(expansion.progress, 0.0);
await tester.tap(find.text('Roses'));
await tester.pumpAndSettle();
expect(find.byType(NowPlayingScreen), findsOneWidget);
expect(expansion.progress, 1.0);
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo beim Abbau des
// Baums als offener Fake-Timer stehen.
+28
View File
@@ -167,6 +167,34 @@ void main() {
expect(auth.istAngemeldet, isFalse);
});
test('merkeVerbleibend übernimmt den Kontingent-Stand und meldet Listener',
() async {
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
var benachrichtigt = 0;
auth.addListener(() => benachrichtigt++);
auth.merkeVerbleibend(37);
expect(auth.verbleibend, 37);
expect(benachrichtigt, 1);
});
test('Abmelden setzt auch den Kontingent-Stand zurück', () async {
final speicher = _MemorySpeicher();
final auth = BakaAuth(
client: MockClient(
(_) async => http.Response(jsonEncode({'token': 'jwt-abc'}), 200)),
speicher: speicher);
await auth.anmelden('Baka', 'geheim');
auth.merkeVerbleibend(42);
await auth.abmelden();
expect(auth.verbleibend, isNull);
});
test('Abmelden löscht Token und Benutzer aus dem Speicher', () async {
final speicher = _MemorySpeicher();
final auth = BakaAuth(
+120
View File
@@ -0,0 +1,120 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/gast_zugang.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);
}
void main() {
test('Ohne Token ist gastHeader leer', () {
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher(),
);
expect(gast.hatToken, isFalse);
expect(gast.gastHeader, isEmpty);
});
test('holeToken() speichert den Token bei Erfolg', () async {
final speicher = _MemorySpeicher();
final gast = GastZugang(
client: MockClient(
(_) async => http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
speicher: speicher,
);
final fehler = await gast.holeToken();
expect(fehler, isNull);
expect(gast.hatToken, isTrue);
expect(gast.gastHeader, {'X-Guest-Token': 'g-1'});
expect(speicher.werte['guest_token'], 'g-1');
});
test('holeToken() meldet einen Fehler bei einer toten Verbindung',
() async {
final gast = GastZugang(
client: MockClient((_) async => throw Exception('down')),
speicher: _MemorySpeicher(),
);
final fehler = await gast.holeToken();
expect(fehler, 'Proxy nicht erreichbar');
expect(gast.hatToken, isFalse);
});
test('holeToken() meldet einen Fehler bei einem Server-Fehlerstatus',
() async {
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher(),
);
final fehler = await gast.holeToken();
expect(fehler, 'Gast-Zugang fehlgeschlagen (500)');
expect(gast.hatToken, isFalse);
});
test('holeToken() meldet einen Fehler bei unlesbarer Antwort', () async {
final gast = GastZugang(
client: MockClient((_) async => http.Response('nicht json', 200)),
speicher: _MemorySpeicher(),
);
final fehler = await gast.holeToken();
expect(fehler, 'Proxy antwortet unverständlich (200)');
expect(gast.hatToken, isFalse);
});
test('holeToken() meldet einen Fehler ohne guest_token-Feld', () async {
final gast = GastZugang(
client: MockClient((_) async => http.Response(jsonEncode({}), 200)),
speicher: _MemorySpeicher(),
);
final fehler = await gast.holeToken();
expect(fehler, 'Proxy hat keinen Gast-Token geliefert');
expect(gast.hatToken, isFalse);
});
test('laden() stellt einen gespeicherten Gast-Token wieder her', () async {
final speicher = _MemorySpeicher();
speicher.werte['guest_token'] = 'g-alt';
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: speicher,
);
await gast.laden();
expect(gast.hatToken, isTrue);
expect(gast.gastHeader, {'X-Guest-Token': 'g-alt'});
});
test('merkeVerbleibend übernimmt den zuletzt gemeldeten Stand', () {
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher(),
);
gast.merkeVerbleibend(3);
expect(gast.verbleibend, 3);
});
}
+168
View File
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/gast_zugang.dart';
import 'package:melo/services/yt_download_service.dart';
class _MemorySpeicher implements TokenSpeicher {
@@ -284,4 +285,171 @@ void main() {
expect((jsonDecode(auftrag!) as Map)['cookies'], isFalse);
});
group('Gast-Zugang', () {
test('Mit Gast-Token statt Anmeldung wird der Proxy trotzdem gefragt',
() async {
final speicher = _MemorySpeicher();
speicher.werte['guest_token'] = 'gast-xyz';
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: speicher,
);
await gast.laden();
final dienst = YtDownloadService(
auth: BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher()),
gast: gast,
client: MockClient((req) async {
if (req.url.path == '/api/yt-dl') {
expect(req.headers['X-Guest-Token'], 'gast-xyz');
expect(req.headers.containsKey('Authorization'), isFalse);
return http.Response(
jsonEncode({
'titel': 'Gast-Lied',
'dauer': 30,
'mp3_url': '/api/dl/g.mp3',
'guest_remaining': 4,
}),
200);
}
return http.Response.bytes([1], 200);
}),
);
final ergebnis = await dienst.herunterladen('https://youtu.be/abc',
zielOrdner: ziel.path);
expect(ergebnis, isNotNull);
expect(gast.verbleibend, 4);
});
test('Ohne Anmeldung UND ohne Gast-Token wird gar nicht erst gefragt',
() async {
var aufrufe = 0;
final dienst = YtDownloadService(
auth: BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher()),
client: MockClient((_) async {
aufrufe++;
return http.Response('', 200);
}),
);
final ergebnis = await dienst.herunterladen('https://youtu.be/abc',
zielOrdner: ziel.path);
expect(ergebnis, isNull);
expect(dienst.fehler, 'Bitte zuerst beim Baka-Konto anmelden');
expect(aufrufe, 0);
});
test('401 bei Gast-Zugang holt automatisch einen neuen Gast-Token',
() async {
var tokenAufrufe = 0;
final speicher = _MemorySpeicher();
speicher.werte['guest_token'] = 'alter-token';
final gast = GastZugang(
client: MockClient((_) async {
tokenAufrufe++;
return http.Response(
jsonEncode({'guest_token': 'neuer-token'}), 200);
}),
speicher: speicher,
);
await gast.laden();
final dienst = YtDownloadService(
auth: BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher()),
gast: gast,
client: MockClient((_) async => http.Response('Unauthorized', 401)),
);
final ergebnis = await dienst.herunterladen('https://youtu.be/abc',
zielOrdner: ziel.path);
// Das Token-Nachholen läuft unawaited im Hintergrund.
await Future<void>.delayed(Duration.zero);
expect(ergebnis, isNull);
expect(dienst.fehler, 'Gast-Zugang abgelaufen — bitte erneut versuchen');
expect(tokenAufrufe, 1);
});
});
group('Cloud-Kontingent', () {
test('cloud_remaining aus der Erfolgsantwort landet in auth.verbleibend',
() async {
final auth = await _angemeldeteAuth();
final dienst = YtDownloadService(
auth: auth,
client: MockClient((req) async {
if (req.url.path == '/api/yt-dl') {
return http.Response(
jsonEncode({
'titel': 'Cloud-Lied',
'dauer': 30,
'mp3_url': '/api/dl/c.mp3',
'cloud_remaining': 99,
}),
200);
}
return http.Response.bytes([1], 200);
}),
);
final ergebnis = await dienst.herunterladen('https://youtu.be/abc',
zielOrdner: ziel.path);
expect(ergebnis, isNotNull);
expect(auth.verbleibend, 99);
});
test(
'Bei Gast-Zugriff wird cloud_remaining nicht gelesen, selbst wenn es '
'im Body steht', () async {
final speicher = _MemorySpeicher();
speicher.werte['guest_token'] = 'gast-xyz';
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: speicher,
);
await gast.laden();
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final dienst = YtDownloadService(
auth: auth,
gast: gast,
client: MockClient((req) async {
if (req.url.path == '/api/yt-dl') {
return http.Response(
jsonEncode({
'titel': 'Gast-Lied',
'dauer': 30,
'mp3_url': '/api/dl/g.mp3',
'guest_remaining': 4,
// Sollte serverseitig nie gleichzeitig vorkommen, aber
// falls doch: darf nicht in auth.verbleibend landen.
'cloud_remaining': 77,
}),
200);
}
return http.Response.bytes([1], 200);
}),
);
final ergebnis = await dienst.herunterladen('https://youtu.be/abc',
zielOrdner: ziel.path);
expect(ergebnis, isNotNull);
expect(gast.verbleibend, 4);
expect(auth.verbleibend, isNull);
});
});
}
+57
View File
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/gast_zugang.dart';
import 'package:melo/services/yt_search_service.dart';
class _MemorySpeicher implements TokenSpeicher {
@@ -200,5 +201,61 @@ void main() {
await dienst.suchen('zweite suche');
expect(dienst.treffer.single.title, 'Zweites');
});
test('Mit Gast-Token statt Anmeldung wird trotzdem gesucht', () async {
final speicher = _MemorySpeicher();
speicher.werte['guest_token'] = 'gast-xyz';
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: speicher,
);
await gast.laden();
final dienst = YtSearchService(
auth: BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher()),
gast: gast,
client: MockClient((req) async {
expect(req.headers['X-Guest-Token'], 'gast-xyz');
expect(req.headers.containsKey('Authorization'), isFalse);
return http.Response('[]', 200);
}),
);
await dienst.suchen('roses');
expect(dienst.fehler, isNull);
});
test('401 bei Gast-Zugang holt automatisch einen neuen Gast-Token',
() async {
var tokenAufrufe = 0;
final speicher = _MemorySpeicher();
speicher.werte['guest_token'] = 'alter-token';
final gast = GastZugang(
client: MockClient((_) async {
tokenAufrufe++;
return http.Response(
jsonEncode({'guest_token': 'neuer-token'}), 200);
}),
speicher: speicher,
);
await gast.laden();
final dienst = YtSearchService(
auth: BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher()),
gast: gast,
client: MockClient((_) async => http.Response('Unauthorized', 401)),
);
await dienst.suchen('roses');
await Future<void>.delayed(Duration.zero);
expect(dienst.fehler, 'Gast-Zugang abgelaufen — bitte erneut versuchen');
expect(tokenAufrufe, 1);
});
});
}