Author SHA1 Message Date
Hermes (Server)andClaude Sonnet 5 8a5bbf6433 Sync-Ausbau abgeschlossen: SYNC_PLAN.md entfernen (kein Dauerdokument)
Alle 13 Tasks + Whole-Branch-Review + zwei Fix-Wellen fertig, PR #5 gegen
fix/p0-vollwertigkeit erstellt. Übergabe-Datei laut eigener Kopfzeile
löschen, sobald die Implementierung fertig ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
2026-08-27 14:20:01 +02:00
Hermes (Server) 8126a16520 Race behoben: Playlist-Wiederherstellung nur nach abgeschlossenem Sync-Lauf 2026-08-27 14:11:36 +02:00
Hermes (Server) b7c8be6f0f Review-Funde: Playlist-Wiederherstellung nach erstem Sync, CHANGELOG korrigiert, Dedup-sicheres songByCloudId 2026-08-27 14:04:28 +02:00
Hermes (Server) b13ed6271d CHANGELOG: Sync-Ausbau (Favoriten-Fix, Auswahl-Upload, Einzel-Offline, Bericht) 2026-08-27 13:50:58 +02:00
Hermes (Server)andClaude Sonnet 5 faabf28abc Einseitige Playlist-Sicherung: melden, merken, bei leerer Tabelle holen
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
2026-08-27 13:43:20 +02:00
Hermes (Server) 0519c68b26 Playlisten-Endpunkte fuer die einseitige Sicherung (ohne DELETE) 2026-08-27 13:37:37 +02:00
Hermes (Server) 3cd88565ec Schema 11: Playlists.cloudId fuer die einseitige Sicherung 2026-08-27 13:31:33 +02:00
Hermes (Server)andClaude Sonnet 5 11a01d1a09 Sync-Ausbau: Stand nach Task 1-9 sichern (Übergabe für neue Session)
Tasks 1-9 fertig, reviewed, committed. Vor Task 10-12 (Playlist-Sicherung)
wartet die Umsetzung auf Dustins Test-Playlist am Server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
2026-08-27 13:23:22 +02:00
Hermes (Server) 676f2c5f29 Was-ist-neu-Bericht als In-App-Dialog nach dem Abgleich 2026-08-27 12:40:21 +02:00
Hermes (Server) 9de61bbf94 Erfolgs-Flag, Zeitstempel-Snapshot und Zaehler fuer den Sync-Bericht 2026-08-27 12:34:32 +02:00
Hermes (Server) 46ea9ef344 CHANGELOG-Eintrag zurücknehmen (Sammel-Eintrag folgt in Task 13) 2026-08-27 12:29:17 +02:00
Hermes (Server) 8e3c986d13 Einzelne Server-Titel offline nehmen und wieder entfernen 2026-08-27 12:24:56 +02:00
Hermes (Server) fcf53dcb6c Auswahl-Modus: Auf den Server laden (nur in Meine Musik) 2026-08-27 12:14:07 +02:00
Hermes (Server) 78968fd6b8 Gezielter Upload mit Abbrechen aus dem Auswahl-Modus 2026-08-27 11:31:37 +02:00
Hermes (Server) 8d84a88a9c Herz-Tipp meldet den Favoriten sofort deterministisch an die Cloud 2026-08-27 11:23:38 +02:00
Hermes (Server) 5dc80931e2 Favoriten additiv abgleichen statt ersetzen; Upload-Timeout ist Einzelfehler 2026-08-27 10:55:00 +02:00
Hermes (Server) 09d24abf4d Favoriten-GET gehaertet + deterministisches setzeFavorit 2026-08-27 10:47:46 +02:00
Hermes (Server) 6250fb78c1 Merge-Entscheidungen als reine Funktionen (sync_merge.dart) 2026-08-27 10:41:44 +02:00
Hermes (Server)andClaude Sonnet 5 40171a7c0e Kontext-Sicherung: SYNC_PLAN.md (Übergabe für neue Session)
Stand, nächster Schritt (Implementierungsplan Task 1) und offene
Punkte für den Sync-Ausbau festgehalten, bevor die Session wegen
vollem Kontextfenster endet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-27 10:34:52 +02:00
Hermes (Server)andClaude Sonnet 5 1940e2b790 Sync-Ausbau: Löschpfad gefixt — Spec + Plan entblockt
Hermes hat den irreversiblen Löschpfad in melo_cloud.py behoben: Der
Dedup-Zweig von upload() stellt die Datei jetzt aus den hochgeladenen
Bytes wieder her, wenn registry_pfad(sid) leer ist, statt sie zu
verwerfen.

End-to-end verifiziert (eigene Testdatei, Fake-Nutzer, Testdaten danach
restlos entfernt — Registry vor und nach dem Test bei 325 Titeln):
Upload → beide Kopien da · Löschung → beide weg, Download 404 (Bug
reproduziert) · erneuter Upload → beide Kopien zurück, Download
bitgenau identisch mit dem Original.

Damit ist die letzte offene Frage der Spec (A6) beantwortet und keine
Stufe mehr blockiert. Tasks 5-7 des Plans sind entblockt; Tasks 10-12
hängen nur noch an der Produktfrage "wird Feature 2 überhaupt gebaut?"
(0 Playlisten am Server).

Nebenbefund aus dem Test, als Backlog-Notiz festgehalten: _link_user()
legt im Dedup-Zweig einen dritten Hardlink unter users/<user>/ an, den
_entferne_datei_wenn_verwaist() nicht abräumt. Server-Hygiene für
Hermes, kein Datenverlust.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-27 10:31:49 +02:00
Hermes (Server)andClaude Sonnet 5 2a1cf9cdc2 Sync-Ausbau: Spec nachgeschärft + Implementierungsplan (13 Tasks)
Spec nach dem Review-Panel-Urteil überarbeitet (alle 17 Aktionspunkte).
Der Umfang schrumpft deutlich: 6→5 Ziele, 4→2 neue Dateien, neue
Dependencies 1→0. SSE, persistente Notification und der beidseitige
Playlist-Merge werden spätere Stufen; der Favoriten-Fix schreibt jetzt
additiv (POST /favorites gestrichen), womit der Datenverlust-Bug
strukturell unmöglich wird statt nur per Regel verhindert.

Implementierungsplan: 13 Tasks nach TDD, Favoriten-Fix zuerst (hängt an
keiner offenen Frage). Gegengelesen und geprüft; die Prüfung fand drei
echte Server-Vertragsfehler, die in den eigenen Tests grün geworden
wären (playlist.id statt id, positions statt song_ids, not_found ohne
error-Feld) — alle korrigiert und am Servercode belegt.

OFFEN: Rückfrage an Dustin zum irreversiblen Löschpfad (siehe Spec,
Abschnitt "OFFENE ENTSCHEIDUNG"). Tasks 5-7 und 10-12 sind bis dahin
als blockiert markiert; Tasks 1-4 können sofort starten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-27 02:40:38 +02:00
Hermes (Server)andClaude Sonnet 5 852c17cd48 Review-Panel: Sync-Ausbau-Spec (Verdikt 6/10, nachschärfen)
Adversariales 5-Reviewer-Panel (Opus) mit 2 Debattenrunden,
Vollständigkeits-Audit, konsolidierter Faktenprüfung (~70 Belege,
keine Halluzination) und Richterurteil.

Ergebnis: SPEC NACHSCHÄRFEN DANN FREIGEBEN. Kein P0 im Geltungsbereich.
17 Aktionspunkte, wichtigste: POST /favorites (Voll-Ersatz) streichen
statt Regel, SSE und beidseitigen Playlist-Merge herausnehmen,
irreversiblen Bestands-Löschpfad benennen (Backlog-P0, Rückfrage an
Dustin offen).

Bemerkenswert: Der vom Panel selbst empfohlene Basis-Snapshot wurde
von der Verifikation als gefährlicher entlarvt als das Problem, das
er lösen sollte — abgelehnt.

Reviewer-Rohtexte bleiben lokal (.gitignore), nur Bericht + Urteil
im Repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-27 01:35:55 +02:00
Hermes (Server)andClaude Sonnet 5 3a376faf50 Spec: Sync-Ausbau (Auswahl-Upload, Einzel-Song-Offline, Merge-Fixes, SSE)
Design-Dokument für den Sync-Ausbau der Android-App, mit Dustin im
Brainstorming abgestimmt (Workflow-Bestandsaufnahme über 6 Schichten:
App-Sync, Offline, melo_cloud.py, Navidrome, Cross-Platform, Melo v2).
Kern: Favoriten-Merge-Fix (Datenverlust-Bug), gezielter Upload im
Auswahl-Modus, Einzel-Song-Offline, Playlist-Union-Sync,
Sync-Notification + Bericht, Echtzeit per SSE. Cross-Platform
Mac+Windows bewusst als eigenes Folgeprojekt ausgeklammert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-27 00:04:06 +02:00
Hermes (Server)andClaude Sonnet 5 eee94467a0 .gitignore: Subagent-Driven-Development-Ledger ausschließen
.superpowers/ enthält lokalen Laufzeit-Zustand (Ledger, Briefs,
Review-Pakete) der subagent-driven-development-Skill — kein
Projektstand, gehört nie in einen Commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-26 20:30:27 +02:00
Hermes (Server)andClaude Sonnet 5 9f08286e50 Implementierungsplan: YouTube-Gast-Zugang (App-Seite)
7 Tasks nach TDD: GastZugang-Service, Gast-Unterstützung in
YtDownloadService/YtSearchService, Verdrahtung in main.dart, UI in
Download-Tab und YT-Suche-Tab (bewusste Wahl Anmelden/Als-Gast-
fortfahren + Tages-Zähler), Abschluss-Verifikation. Umsetzung auf
neuem Branch feature/youtube-gast-zugang (Task 1, Step 0).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-26 20:28:41 +02:00
Hermes (Server)andClaude Sonnet 5 2d94a3aa26 Spec: YouTube-Gast-Zugang mit Tages-Limit
Design-Dokument für den neuen anonymen Gast-Zugang zum YouTube-Proxy
(5 Downloads/Tag, keine Cookies) neben dem bestehenden
Baka-Auth-Cloud-Zugang. Mit Dustin im Brainstorming abgestimmt:
geräte-gebundener Gast-Token, bewusste Wahl in der UI, Reset nach
Server-Kalendertag, Suche bleibt unbegrenzt. Server-seitiger Teil
(yt_proxy.py) liegt außerhalb dieses Worktrees — die Spec beschreibt
den Vertrag dafür.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-26 20:16:27 +02:00
Hermes (Server)andClaude Sonnet 5 4aa295986f YouTube-Tab: Hinweistext bei Passwort-Drift präzisieren
Root-Cause-Analyse zu Bakas Login-Problem (mit Hermes): Ihr Baka-Auth-
Account existiert, aber Navidrome- und Baka-Auth-Passwort sind
auseinandergelaufen. Der Rückfall-Hinweis erklärt jetzt, dass eine
einmalige manuelle Anmeldung mit dem aktuellen Baka-Passwort die
beiden Passwörter wieder synchronisiert (baka_auth.py macht das bei
jedem erfolgreichen Login automatisch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-26 20:09:43 +02:00
Hermes (Server)andClaude Sonnet 5 f604dc7635 YouTube-Tab: Server-User brauchen keine eigene Baka-Anmeldung mehr
Server-User (Dustin, Baka, Tinker) melden sich beim Öffnen des
YouTube-Bereichs automatisch im Hintergrund mit ihren Navidrome-
Zugangsdaten beim Baka-Konto an (BakaAuth.autoAnmelden) — die
manuelle Anmeldung bleibt nur für Gäste sichtbar. Schlägt die
Auto-Anmeldung fehl, gibt es einen Rückfallweg zur manuellen
Anmeldung, statt den User auszusperren. Login-Buttons vergrößert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-26 00:18:49 +02:00
Hermes (Server)andClaude Sonnet 5 f0313724d4 Equalizer-Fix für Xiaomi/MIUI (Dolby Atmos) + In-App-YouTube-Suche als Extra-Tab
- openSystemPanel(): Xiaomi-Sound-Settings-Intent zuerst, dann
  Settings.ACTION_SOUND_SETTINGS, dann Android-Standard-Intent als
  Fallback. Behebt "kein System-Klangeffekte" trotz Dolby Atmos auf
  MIUI/HyperOS (POCO X7 Pro), wo der Standard-Intent keine Activity findet.
- Neuer Tab "YT-Suche": YouTube-Suche über den bestehenden
  /api/search-Endpunkt des Baka-Proxys, Ergebnisliste mit Thumbnail
  (YouTube-CDN), Titel, Dauer und Download-Knopf pro Treffer — derselbe
  Download-Weg wie im bestehenden Download-Tab.

574 Tests grün, flutter analyze ohne Befund, Kotlin kompiliert sauber
(gradlew :app:compileDebugKotlin). Xiaomi-Fix konnte nicht auf echter
Hardware getestet werden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VM2JK5mV7AL1g2Rt6H6h9w
2026-08-25 21:03:46 +02:00
Hermes (Server)andClaude Sonnet 5 9e65ddcbb5 Worktree-Lock-Konvention gegen Session-Doppelarbeit dokumentieren
Aus der Workflow-Analyse 2026-08-25: zwei Sessions bekamen kurz
hintereinander denselben Auftrag für diesen Worktree. .claude-worktree-lock
(gitignored) markiert künftig, wenn hier gerade aktiv gearbeitet wird;
Mechanismus ist im melo-app-workflow-Skill dokumentiert (dort auch die
korrigierte Test-Timeout-Vorgabe).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VM2JK5mV7AL1g2Rt6H6h9w
2026-08-25 19:15:19 +02:00
Hermes (Server)andClaude Sonnet 5 da012a3dca Tab-Reihenfolge, konfigurierbare Meine-Musik-Sektionen, Berechtigungen beim Start, schärferes Sperrbildschirm-Cover
- Tabs: Meine Musik, Suchen, Download, Favoriten (vorher Download vor Suchen)
- "Meine Musik": Schnellzugriffe (Favoriten/Wiedergabelisten/Zuletzt) einzeln
  über die Einstellungen abschaltbar
- Audio- und Benachrichtigungs-Berechtigung werden jetzt beim ersten Start
  gemeinsam angefragt statt erst bei Bedarf; Hinweisbanner + Einstellungen-
  Button bei dauerhafter Verweigerung (auch für Benachrichtigungen neu)
- Cover-Extraktion beim Android-Scan von 512px auf 1024px erhöht, für ein
  schärferes Cover im Sperrbildschirm
- Fix: notifyListeners() in LibraryService nur noch bei echter Wertänderung
  (verhinderte einen Test-Hang durch den neuen Start-Berechtigungs-Aufruf)

557 Tests grün, flutter analyze ohne Befund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VM2JK5mV7AL1g2Rt6H6h9w
2026-08-25 14:17:13 +02:00
Hermes (Server) 54cb523cf4 Review-Dokument: Nachtrag zu UX-Simulation Teil 3 2026-08-25 10:26:36 +02:00
Hermes (Server) c2f31dee01 UX-Simulation, Teil 3: Abbrechen, Badge, Versionsnummer, Wisch-Strecke
Die vier zuletzt zurückgestellten Punkte aus der UX-Simulation:

- Abbrechen-Knopf für hängende YouTube-Downloads (Future.any gegen ein
  Abbruch-Signal, Anfrage läuft im Hintergrund aus statt die App zu
  blockieren)
- Punkt am inaktiven Download-Unterreiter, wenn dort etwas im Hintergrund
  läuft (SubTabs bekommt einen optionalen badges-Parameter)
- Versionsnummer unter "Über Melo" (neue Abhängigkeit: package_info_plus)
- Mini-Player führt beim Wischen jetzt sichtbar mit und wertet zusätzlich
  zum Schwung auch die zurückgelegte Strecke aus — ein langsames, aber
  weites Wischen wurde vorher komplett ignoriert

Dabei drei bestehende Widget-Tests korrigiert, die den Download-Tab ohne
Provider<DownloadService> aufbauten (hauptmenue_test.dart,
home_shell_test.dart, online_screen_test.dart) — lief bisher nur, weil
niemand danach fragte; das neue Badge tut das jetzt app-weit.

550 Tests grün (vorher 539), flutter analyze ohne Befund.
2026-08-25 10:25:43 +02:00
Hermes (Server) 8f80362f08 Review-Dokument: Nachtrag zur UX-Simulation
Ergänzt den bestehenden Review-Bericht um einen Verweis auf den
UX-Simulations-Durchgang vom 2026-08-25, analog zu den Nachträgen für
P2/P3.
2026-08-25 09:50:43 +02:00
Hermes (Server) 5645660a2d UX-Simulation, Teil 2: Player-Feinschliff, Downloads, Einstellungen
Zweiter Block aus der UX-Simulation (39 Funde insgesamt):

- Songtext-Icon deaktiviert statt totem Knopf, wenn kein Ziel bekannt ist
- Sleep-Timer-Restzeit jetzt auch im Sheet sichtbar, nicht nur im Tooltip
- Drag-Handle in der Warteschlange, Wisch-nach-unten schließt den
  Vollbild-Player
- Fehlender Tooltip am Songtext-Sheet-Schließen-Knopf ergänzt
- YouTube-Download: Zeitüberschreitung meldet sich anders als toter Server
- Server-Tab zeigte beim Öffnen kurz fälschlich "nicht verbunden"
- Einstellungen: Favoriten-/Playlisten-Sync von "Speicher" zu
  "Musikserver" verschoben, Navidrome-Eintrag erklärt jetzt seinen Zweck
- Kommentar in geteilte_adresse.dart korrigiert (Verhalten unverändert,
  dafür gibt es einen Test)

Bewusst zurückgestellt: ServerFavoriteButton bleibt lokal-only (dokumentierte
Architektur-Entscheidung, siehe G3 im UX-Review), Live-Wischgeste am
Mini-Player, YouTube-Download-Abbrechen, Download-Badge im inaktiven
Unterreiter, Versionsnummer (neue Abhängigkeit nötig).

539 Tests grün (vorher 537), flutter analyze ohne Befund.
2026-08-25 09:49:47 +02:00
Hermes (Server) 07acf77ba4 UX-Simulation: Mehrfachauswahl überall, Baka-Login, Bestätigungen, Suche
7 Agenten sind die App als normaler Nutzer durchgegangen (Erststart,
Bibliothek, Download, Suche, Favoriten, Player, Einstellungen) und haben
39 Reibungspunkte gefunden. Erster Block:

- Mehrfachauswahl lief nur im Lieder-/Favoriten-Reiter, nicht in
  Kategorie-/Künstler-/Album-Ansichten — jetzt überall, per geteilter
  auswahl_leiste.dart
- Zwei unterschiedlich ausgestattete Favoriten-Listen (Tab vs. Playlisten)
  vereinheitlicht
- Baka-Login zeigt Ladezustand + Fehler im Dialog statt bis zu 15s stumm
  zu bleiben (wie der Navidrome-Login)
- Cache leeren, Navidrome-Abmelden, Download einzeln entfernen fragen jetzt
  nach
- Download-Fehlermeldung bei toter Serververbindung nennt den echten Grund
  statt "War schon heruntergeladen"
- YouTube-Fehlermeldungen sind jetzt rot statt neutralfarben
- Suche schließt die Tastatur nach Auswahl, nennt den Suchbegriff bei
  "Nichts gefunden", einzelne Verlaufseinträge sind entfernbar
- Icon-/Text-Korrekturen (Mikrofon-Icon fälschlich bei Speicherzugriff,
  "Online"-Tab existiert nicht, ReplayGain-Jargon)

537 Tests grün (vorher 533), flutter analyze ohne Befund.
2026-08-25 09:42:41 +02:00
Hermes (Server) 60c57577a3 Cover-Farbe, Teilen aus YouTube, Android Auto, geschärfte Typografie
P3-Liste aus dem UX-Review: Player nimmt Cover-Farbe auf, "Teilen" aus
YouTube trägt die Adresse in den Downloader ein, Android Auto blättert
durch die Bibliothek, Laufweite der Typografie geschärft, Überschriften
vereinheitlicht. 533 Tests grün, flutter analyze ohne Befund.
2026-08-25 09:09:27 +02:00
Hermes (Server)andClaude Opus 5 490e73ce41 Politur: Wischen, wandernde Cover, Alphabet-Sprung, Mehrfachauswahl
Die P2-Liste aus dem Review — Kleinigkeiten, die man einzeln nicht
vermisst und zusammen sofort merkt.

- Das Coverbild wandert beim Oeffnen der Vollbild-Wiedergabe an seinen
  Platz, statt dass ein neuer Bildschirm aufspringt.
- Wischen am Mini-Player: nach oben oeffnen, seitwaerts Titelwechsel. Mit
  Schwelle, weil der Mini-Player zugleich ein Knopf ist und der Finger
  beim Tippen fast immer ein paar Pixel wandert. Nach unten bewusst
  unbelegt, damit niemand die Wiedergabe versehentlich wegschiebt.
- Kuenstlerliste mit Bildern und Albenzahl; dahinter eine Kuenstlerseite
  mit seinen Alben und darunter allen Titeln (dafuer neu: groupByAlbum).
  Bei nur einem Album entfaellt die Zwischenstufe.
- Alphabet-Sprung am rechten Rand, nur bei Namenssortierung und ab 30
  Titeln. Umlaute zaehlen zu ihrem Grundbuchstaben, Ziffern und Zeichen
  unter "#" — dieselbe Regel wie die Sortierung.
- Mehrfachauswahl per langem Druck, mit "Zur Warteschlange" und "Zu
  Wiedergabeliste".
- Abgeschaltete Systemanimationen gelten jetzt ueberall, nicht nur bei den
  Lauf-Balken.
- Querformat und Tablet: der Player legt Cover und Bedienung nebeneinander
  und begrenzt die Textbreite.

Beim Bauen gefunden: der Alphabet-Sprung landete bei absteigender
Sortierung am Ende eines Buchstaben-Blocks statt an seinem Anfang.

Aus dem Code-Review nachgebessert, sieben Punkte: die Alphabet-Leiste
wurde breiter, je weniger Buchstaben sie hatte (bei einem einzigen 528 dp
— die Songliste waere verschwunden); die neue Kuenstlerseite baute alle
Titel sofort und oeffnete damit bei 300 Titeln ebenso viele Abos; die
Bildlauf-Animation im Songtext stuerzte bei abgeschalteten
Systemanimationen ab; die feste Zeilenhoehe war 64 statt 72 und die
Schriftskalierung an 1.0 statt an einer echten Groesse gemessen; bereits
enthaltene Titel wurden in Wiedergabelisten verschoben statt uebersprungen
und trotzdem mitgezaehlt; ein Fehler im Stapel brach still ab; und die
Zurueck-Geste verliess den Bildschirm, statt die Auswahl zu beenden.

Selbst gefunden: in der Kuenstlerliste hing der Datenbank-Strom an
CategoryService und wurde bei jeder Kategorie-Meldung neu aufgesetzt.

Nebenbei aufgeklaert, warum ein neuer Widget-Test endlos hing: Aufbau in
setUp laeuft ausserhalb der Testzone, die drift-Stroeme liefen dort in
echter Zeit, waehrend pumpAndSettle in Testzeit auf sie wartete. Aufbau in
den Testkoerper verschoben. Der aeltere Haenger im BACKLOG hat eine andere
Ursache (db.close() dort nachweislich ausgeschlossen); die Spur ist
notiert.

500 Tests gruen (vorher 457), flutter analyze ohne Befund, Release-APK
gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
2026-08-24 15:09:46 +02:00
Hermes (Server)andClaude Opus 5 0d76d8cc58 Album-Ansicht, mitlaufender Songtext, Cover-Cache, Server-Favoriten
Die letzten vier Punkte aus dem Review-Bericht.

G2 — Server-Bereich: Ein Tipp auf ein Album spielte sofort alles ab;
hineinsehen ging nicht. Jetzt Album- und Kuenstler-Ansicht mit Cover,
Kuenstler, Titelzahl, Gesamtdauer, "Abspielen"/"Zufaellig" und der
Titelliste; Antippen startet an dieser Stelle. Dazu "Mehr Alben laden" —
die Liste hoerte bei 50 auf, ohne dass etwas darauf hinwies.
NICHT umgesetzt: den Server-Bereich aus dem Download-Tab loesen (das
widerspricht Commit 34d6d8e, "Auf Wunsch von Dustin") und eine
Server-Suche (die Suche bleibt ausdruecklich rein lokal).

G8 — Songtext: Traegt der Text Zeitmarken, laeuft er mit; die aktive Zeile
ist hervorgehoben, Antippen springt an die Stelle. Ohne Zeitmarken bleibt
es eine Textwand — die App schaut nach, statt zu raten.

G10 — Cover bleiben liegen. Eigener Ordner, einmal geholt. Der Schluessel
haengt an der Album-Kennung, nicht an der Adresse: die traegt bei jedem
App-Start ein neues Token, und der Ordner waere sonst gewachsen, ohne je
einen Treffer zu liefern. Keine neue Abhaengigkeit.

G3 — Das Herz ist bei Server-Titeln nicht mehr ausgegraut, sondern
favorisiert am Server (star/unstar) — dort, wo der Favoriten-Abgleich sie
auch wieder abholt. Offen bleibt der schwere Teil: Wiedergabezaehler,
Historie und Kategorien braeuchten fuer Server-Titel eine Zeile in der
Bibliothek.

Aus dem Code-Review nachgebessert (sieben Punkte): das Herz behielt beim
Titelwechsel seinen Zustand; recycelte Listenzeilen zeigten kurz das Cover
der vorherigen; der neue Songtext bekam bei Server-Titeln die
Abspiel-Adresse statt der Server-Kennung und fand deshalb nie einen Text;
ein einmal fehlgeschlagenes Cover blieb die ganze Sitzung grau; "Server
nicht erreichbar" sah aus wie "Album hat keine Titel", womit der
Wiederholen-Knopf unerreichbar war; die Kuenstler-Ansicht waere immer leer
geblieben, weil getArtist die Titel gar nicht mitliefert; und zwei
gleichzeitige Cover-Abrufe konnten ein zerschnittenes Bild hinterlassen.

Selbst gefunden: das Haekchen "schon geladen" fragte pro Zeile die Platte
ab, bei jedem Positionswechsel neu. Jetzt einmal je Bildschirm.

457 Tests gruen (vorher 433), flutter analyze ohne Befund, Release-APK
gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
2026-08-23 21:53:31 +02:00
Hermes (Server)andClaude Opus 5 b42e045e67 Alben und Kuenstler offline mitnehmen; Zwischenspeicher bekommt eine Grenze
Bisher landete Musik nur zufaellig auf dem Geraet: was man abspielte, blieb
nebenbei liegen. Gezielt "dieses Album fuer die Zugfahrt" gab es nicht. Und
dieser Zwischenspeicher wuchs unbegrenzt — es gab nur "alles loeschen".

Zwei getrennte Ablagen. Bewusste Downloads liegen NICHT im Cache-Ordner:
den darf Android bei Speichernot jederzeit selbst leeren, und genau das
darf einem mitgenommenen Album nicht passieren. Sie liegen deshalb neben
der Datenbank im Support-Ordner. Die Grenze gilt nur fuer den
Zufalls-Cache; Downloads sind unbegrenzt und werden nie verdraengt.

- Download-Pfeil je Album und Kuenstler, mit Fortschritt und Abbruch
- Rueckfrage ab 30 Titeln samt Groessenschaetzung
- Obergrenze wahlbar (aus / 512 MB / 1 / 2 / 4 / 8 GB), Standard 2 GB;
  am laengsten nicht Gehoertes weicht zuerst
- Liste "Heruntergeladen" mit Titel, Kuenstler, Groesse, einzeln oder alle
  entfernbar (Schema 10)
- Wiedergabe nimmt erst den Download, dann den Zwischenspeicher

Aus dem Code-Review nachgebessert, vier ernste Punkte: "aus" (0 MB)
schaltete das Aufraeumen ab statt das Zwischenspeichern und liess den
Cache unbegrenzt wachsen; wer sich erst waehrend der Sitzung am Server
anmeldete, konnte gar nichts laden; die Verdraengung konnte Dateien
loeschen, die die laufende Warteschlange schon als Quelle eingetragen
hatte (Abbruch mitten im Album, ohne Rueckfall aufs Streamen); und ein
heruntergeladener Titel wurde beim Abspielen ein zweites Mal in den Cache
geholt. Dazu sechs kleinere und der "zuletzt gehoert"-Stempel, der beim
Aufbau der Warteschlange statt beim Abspielen gesetzt wurde.

Nebenbei aufgeraeumt, weil die Dateien ohnehin offen waren: keine Emoji
als Knoepfe mehr, kein Colors.redAccent und kein grey.shade mehr im
ganzen Projekt, und die Einstellungen heissen nicht mehr "Settings".

433 Tests gruen (vorher 411), flutter analyze ohne Befund, Release-APK
gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
2026-08-23 21:31:53 +02:00
Hermes (Server)andClaude Opus 5 80948f1ad5 Listen zeigen, welcher Titel gerade laeuft
Bisher scrollte man durch die Bibliothek und musste raten, wo man ist —
die einzige Rueckmeldung war die Leiste ganz unten.

Drei tanzende Balken ueber dem Coverbild des laufenden Titels, sein Name
in Rot. Die Balken liegen auf dem Cover statt daneben: eine vierte Spalte
haette dem Titel Platz genommen und die Zeile bei jedem Titelwechsel in
der Breite springen lassen. Pausiert bleibt die Markierung stehen, die
Balken stehen still. Bei abgeschalteten Systemanimationen ebenfalls — eine
Endlosschleife ist genau das, was diese Einstellung vermeiden soll.

Die Markierung sitzt in SongZeile und gilt damit in einem Rutsch fuer
Meine Musik, Favoriten, Suche, Kuenstler, Kategorie und "Zuletzt
hinzugefuegt". Warteschlange und Wiedergabeliste bauen ihre Zeilen selbst
(dort steht rechts Entfernen statt Herz und Menue) und haben dasselbe
Zeichen einzeln bekommen.

Nur die laufende Zeile hoert auf den Wiedergabe-Zustand: der meldet auch
den Pufferstand und feuert mehrmals pro Sekunde. Haenge jede sichtbare
Zeile daran, baute sich die halbe Liste staendig neu auf.

Aus dem Code-Review nachgebessert: Startwert fuer den Strom, sonst
erschien die Markierung beim Zurueckscrollen einen Frame zu spaet; die
Zeile aendert beim Wechsel nicht mehr die Form ihres Aufbaus, sonst verlor
das Herz daneben sein Abo und sah kurz leer aus; die Wiedergabeliste
bekam die Markierung ueberhaupt erst (der Changelog hatte sie faelschlich
schon behauptet); der Abdunkel-Schleier nimmt den Eckenradius jetzt
entgegen statt ihn fest zu verdrahten.

Testluecke geschlossen, die der Review benannt hat: geprueft waren nur die
Regel und die Balken je fuer sich, die Verdrahtung dazwischen nicht —
genau dort sassen alle drei Fehler.

411 Tests gruen (vorher 396), flutter analyze ohne Befund, Release-APK
gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
2026-08-23 20:54:45 +02:00
Hermes (Server)andClaude Opus 5 ca28264c0e Suche trennt nach Titel, Kuenstler, Kategorien und Wiedergabelisten
Der Suchen-Tab war 59 Zeilen: ein Textfeld ueber einer flachen
Liederliste, nur ueber Titel/Kuenstler/Album. Wer "Nightcore" eingab,
fand alle Titel dieses Kuenstlers untereinander, aber weder den
Kuenstler selbst noch eine gleichnamige Kategorie oder Liste.

- Vier Abschnitte mit Ueberschrift und Trefferzahl. Alben stecken in
  "Kategorien" — der Album-Name ist in Melo die erste Kategorie eines
  Titels, ein eigener Abschnitt waere dieselbe Liste zweimal.
- Treffer am Wortanfang zuerst, dann alphabetisch.
- Entprellt (250 ms); vorher baute jeder Tastendruck alles neu auf.
- Letzte acht Suchen, gemerkt beim Abschicken, mit Loeschen-Knopf.
- Der Tab folgt jetzt dem Aufbau der uebrigen Tabs (war ein Scaffold
  mit eigener AppBar innerhalb eines Tabs).
- Bewusst rein lokal, ohne Server-Abfrage.

Herausgeloest, weil es sonst eine dritte Kopie gegeben haette:
TitelListenScreen ("Ueberschrift + Liederliste", stand zweimal wortgleich
im Baum) und SongZeile (die Suche setzt einzelne Zeilen in ihre
Abschnitte; eine ganze SongList waere dort verschachteltes Scrollen).

Aus dem Code-Review nachgebessert: Suche und Zuordnung benutzen dieselbe
Normalform, sonst zeigte ein Treffer "0 Titel" und oeffnete eine leere
Liste; Kategorien ohne lebende Titel fallen weg; alle Abschnitte sind
gedeckelt; die Drift-Stroeme werden nicht mehr je Rebuild neu abonniert;
das Loeschkreuz erscheint sofort statt nach 250 ms.

Tot geworden und entfernt: MeloDb.searchSongs samt Test.

396 Tests gruen (vorher 360), flutter analyze ohne Befund, Release-APK
gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
2026-08-23 20:08:40 +02:00
Hermes (Server)andClaude Opus 5 d15867d962 Positions-Timer ueberlebt stop(); K4 im Review-Bericht richtiggestellt
stop() brach _positionRecordTimer ab. Der Timer entsteht nur einmal im
Konstruktor, war also fuer den Rest der Sitzung weg. Da auch das System
stop() aufruft (Benachrichtigung wegwischen), merkte sich die App danach
keine Hoerposition mehr und scrobbelte nicht — bis zum Neustart und ohne
Hinweis. Der Timer steigt ohnehin selbst aus, solange nichts laeuft;
abgebrochen wird er jetzt nur noch in dispose().

Dazu eine Korrektur am eigenen Review: K4 behauptete, die Alben-Achse sei
verlorengegangen. Sie wurde bewusst durch "Kategorie" ersetzt — der
Album-Name ist in Melo die erste Kategorie eines Titels
(category_list.dart:12, kategorienAusTags). Was wirklich fehlt, ist die
Album-Detailansicht und die Tracknummer, die gar nicht eingelesen wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
2026-08-23 18:51:18 +02:00
Hermes (Server)andClaude Opus 5 e6d5f1c9ee Fuenf P0-Fehler behoben: Benachrichtigung, Offline, Cover, Warteschlange
Ergebnis einer Durchsicht der App gegen den Massstab "Apple Music".
Alle fuenf Punkte waren keine fehlenden Funktionen, sondern Versprechen,
die die Oberflaeche gab und der Code nicht hielt.

- POST_NOTIFICATIONS fehlte im Manifest. Auf Android 13+ blieb die
  Wiedergabe-Benachrichtigung dadurch unsichtbar. Das Recht wird erst
  erfragt, wenn wirklich ein Titel laeuft.
- Der Offline-Schalter wurde nirgends gelesen. Er greift jetzt an allen
  drei Wegen in die Warteschlange; uebersprungene Titel werden benannt.
- CoverImage kannte nur file://, und der Server-Browser gab die
  Cover-Adresse gar nicht erst mit. Server-Titel waren deshalb immer ein
  graues Notensymbol.
- Warteschlange entfernen/umsortieren baute die ganze Queue neu auf:
  hoerbarer Aussetzer und doppelte Wiedergabe-Zaehlung. Jetzt mutiert nur
  der betroffene Eintrag, die Buchfuehrung steht vor dem Player.
- Das Herz bei Server-Titeln legte einen Favoriten mit leerer Kennung an,
  der nie in der Liste auftauchte. Es ist jetzt ausgegraut.

Nachgebessert aus dem Code-Review: Offline-Meldung an die Stelle geholt,
wo sie greifen kann; Queue-Buchfuehrung vor den Player gezogen;
Fehlerpfad fuer die Berechtigungs-Abfrage ergaenzt.

360 Tests gruen (vorher 328), flutter analyze ohne Befund, Release-APK
gebaut und POST_NOTIFICATIONS darin nachgeprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
2026-08-23 18:34:26 +02:00
Hermes (Server)andClaude Opus 5 34d6d8e52a Zweiter Tab heisst wieder "Download" statt "Online"
Auf Wunsch von Dustin. Der Inhalt bleibt unveraendert (YouTube-Downloader
und Server-Bibliothek als Unterreiter), nur die Beschriftung aendert sich.
Die vier Bereiche: Meine Musik, Download, Suchen, Favoriten.

- Symbol ebenfalls zurueck auf Icons.download_outlined/download. Das ist
  kein neuer Entwurf: genau dieses Symbol trug der Tab, bevor er in
  Commit a625d70 in "Online" umbenannt wurde.
- Die Ueberschrift im Bereich selbst stand noch auf "Online" — ein Tab
  "Download", der auf einen Bildschirm "Online" fuehrt, waere verwirrend.
  Zieht jetzt nach.
- Tests und Testbeschreibungen entsprechend angepasst.

328 Tests gruen (1 uebersprungen), flutter analyze ohne Befund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
2026-08-21 14:22:36 +02:00
Hermes (Server)andClaude Opus 5 5b6fa949a9 Hauptmenue wieder deutlich sichtbar
Entwarnung vorweg: es fehlt nichts. Alle vier Bereiche (Meine Musik,
Online, Suchen, Favoriten) standen durchgehend in der Bottom-Nav;
lib/main.dart wurde von der UI-Politur gar nicht angefasst.

Aber die Politur hatte das Menue abgeschwaecht — zwei eigene Fehler:
- selectedLabelStyle/unselectedLabelStyle bekamen beide fontSize 12. Damit
  verlor der aktive Eintrag den Groessenunterschied (Flutter-Vorgabe 14 zu
  12), an dem man erkennt, wo man ist. Jetzt keine fontSize mehr.
- unselectedItemColor stand auf text3 (Beiwerk-Stufe). Ein Hauptmenue ist
  kein Beiwerk -> text2.

Zusaetzlich deutlicher: Haarlinie ueber der Leiste (auf Schwarz ging sie
sonst im Inhalt auf), gefuellte Symbole fuer den aktiven Bereich (zweites
Merkmal neben der Farbe) und fetterer aktiver Eintrag.

Neu: test/hauptmenue_test.dart — prueft Vorhandensein, Sichtbarkeit auf dem
Bildschirm, Position am unteren Rand, Wechsel per Tipp und die Abhebung des
aktiven Bereichs. Damit kann keine weitere Politur das Menue unbemerkt
abschwaechen.

Nebenbei eine Test-Falle geloest: await db.close() nach einem tester.tap
laesst den Lauf unbegrenzt haengen (drift plant eine Aufraeum-Aufgabe ein,
die der Test-Rahmen nicht mehr abarbeitet). Rezept im Test dokumentiert.
Beim bekannten Haenger in song_detail_sheet_test.dart hilft es NICHT —
gegengeprueft und als eigene Ursache im BACKLOG vermerkt.

328 Tests gruen (10 neue, 1 uebersprungen), flutter analyze ohne Befund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
2026-08-21 14:13:11 +02:00
Hermes (Server)andClaude Opus 5 1547f43099 Merge: UI-Politur (Kontrast, Abstaende, Typografie, Bewegung)
Kein Redesign — Schwarz/Rot, 4 Tabs und Lieder/Kategorie unveraendert.

- Textkontrast: white38 (3,4:1) verfehlte die WCAG-Grenze von 4,5:1 an 17
  Stellen. Drei benannte Stufen ersetzen ihn; ein Test rechnet die
  Verhaeltnisse bei jedem Lauf nach.
- Reiter: 48 dp statt ~38 (Material 3), mit Rueckmeldung beim Antippen und
  weichem Uebergang; Auswahl auch fuer Vorlesehilfen erkennbar.
- Abstaende auf 8er-Raster, Schriftgroessen als 7-stufige Skala.
- Mini-Player ueberarbeitet, Layout-Widerspruch (Expanded um feste Hoehe)
  entschaerft.
- Emoji aus Bedienelementen entfernt.

318 Tests gruen (1 uebersprungen, siehe BACKLOG.md), analyze ohne Befund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
2026-08-21 12:59:27 +02:00
129 changed files with 21781 additions and 869 deletions
+10
View File
@@ -1,3 +1,9 @@
# Claude-Session-Koordination (lokaler Laufzeit-Zustand, siehe melo-app-workflow-Skill)
.claude-worktree-lock
# Subagent-Driven-Development-Ledger/Artefakte (lokaler Laufzeit-Zustand)
.superpowers/
# Miscellaneous
*.class
*.log
@@ -43,3 +49,7 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# Review-Panel-Zwischenstände (Reviewer-Rohtexte, ~500 KB) — nur der
# Abschlussbericht und das Richter-Urteil gehören ins Repo
docs/reviews/*/state/
@@ -0,0 +1,418 @@
# Design- & Feature-Review: Melo vs. Apple Music / Spotify
**Review-ID:** melo_vollwertigkeit_20260823
**Datum:** 2026-08-23
**Umfang:** gesamte App (`lib/`, 56 Dateien, 14.721 Zeilen), Android-Manifest, Referenz-Bilder
**Fokus:** Visual · Usability · Funktionsumfang · Code
**Werkzeuge:** ui-ux-pro-max (design-system + flutter stack + pro-rules), ui-design:design-review
**Verifizierter Ist-Zustand:** `flutter analyze`*No issues found*. `flutter test`*328 bestanden, 1 übersprungen* (der bekannte Hänger aus BACKLOG.md).
---
## Summary
Melo ist technisch überdurchschnittlich sauber: echtes Design-Token-System, WCAG-geprüfte
Textfarben, Tombstone-fähiges Schema ab Tag 1, hohe Testabdeckung, deutschsprachige
Erklär-Kommentare an genau den Stellen, wo früher Bugs saßen. Das ist deutlich besser als
der Durchschnitt eines Hobby-Projekts.
Als **vollwertige Musik-App im Sinne von Apple Music ist sie es aber noch nicht.** Es fehlen
nicht Details, sondern drei tragende Säulen: **die Album-Detailansicht** (siehe die Korrektur
unter K4), **echtes Offline-Management** und **Cover-Art für Server-Titel**. Dazu kommen zwei stille Fehler, die man
der App nicht ansieht: der Offline-Schalter tut nichts, und ohne `POST_NOTIFICATIONS`
erscheint auf Android 13+ keine Wiedergabe-Benachrichtigung.
**Gefundene Punkte:** 26 → 5 kritisch · 10 groß · 7 klein · 4 Vorschläge
> **Stand 2026-08-23, nach der Umsetzung.** Erledigt: alle fünf P0-Punkte
> (K1K5) sowie G1G10. Details im CHANGELOG.
>
> Zwei Teilforderungen aus **G2** sind bewusst **nicht** umgesetzt: den
> Server-Bereich aus dem Download-Tab zu lösen widerspricht Commit
> `34d6d8e` („Auf Wunsch von Dustin"), und eine Server-Suche widerspricht
> der Vorgabe, dass die Suche rein lokal bleibt.
>
> Aus **G3** bleibt der schwere Teil offen: Wiedergabezähler, Historie und
> Kategorien für reine Server-Titel bräuchten eine Zeile in der Bibliothek.
>
> **Nachtrag 2026-08-24:** Die **P2-Liste** ist ebenfalls abgearbeitet
> (Hero-Übergang, Wischgesten, Künstlerseite mit Alben, Reduced Motion
> app-weit, Querformat/Tablet, Alphabet-Sprung, Mehrfachauswahl).
>
> **Nachtrag 2026-08-24 (zweiter):** Auch die **P3-Liste** ist abgearbeitet —
> Cover-Farbe im Player, Teilen-Intent, Android Auto. Bei V2 (eigene Schrift)
> hat Dustin entschieden, bei der System-Schrift zu bleiben und stattdessen
> die Typografie zu schärfen; das ist umgesetzt.
>
> Offen bleibt damit **K4** in seiner
> korrigierten Fassung (Album-Detailansicht **lokal**, mit Tracknummer und
> Schema-Erweiterung — die Server-Seite davon ist mit G2 erledigt).
>
> **Nachtrag 2026-08-25 (UX-Simulation):** Ergänzend zu diesem Code-Review
> ist die App per Multi-Agent-Simulation als normaler Nutzer durchgegangen
> worden — 7 Flows (Erststart, Bibliothek, Download, Suche, Favoriten,
> Player, Einstellungen), 39 Funde. Details im CHANGELOG. Kurzfassung:
> Mehrfachauswahl lief nur in zwei von vier Songlisten-Kontexten, der
> Baka-Login blieb bei Fehlern bis zu 15s stumm, mehrere destruktive
> Aktionen (Cache leeren, Navidrome-Abmelden) hatten keine Rückfrage — alles
> behoben. Bewusst nicht angefasst: `ServerFavoriteButton` bleibt wie hier
> unter G3 beschrieben lokal-only; das ist keine neue Erkenntnis, sondern
> dieselbe offene Baustelle.
>
> **Nachtrag 2026-08-25 (zweiter, UX-Simulation Teil 3):** Auch die zuerst
> zurückgestellten Kleinigkeiten sind erledigt — Abbrechen-Knopf für
> YouTube-Downloads, Hintergrund-Punkt am inaktiven Download-Unterreiter,
> Versionsnummer unter "Über Melo" (neue Abhängigkeit `package_info_plus`)
> und ein Mini-Player, der beim Wischen sichtbar mitführt und zusätzlich
> zum Schwung die Strecke auswertet. `ServerFavoriteButton`/G3 bleibt
> weiterhin bewusst offen.
---
## Kritische Punkte (P0)
### K1 — Offline-Modus ist ein toter Schalter
**Ort:** `lib/services/offline_mode.dart` · `lib/settings/settings_screen.dart:190`
**Kategorie:** Usability / Korrektheit
`OfflineMode` speichert ein Bool in den SharedPreferences und ruft `notifyListeners()`.
Das war's. Verifiziert per Volltextsuche: **kein einziger Leser** außerhalb von
`main.dart` (Registrierung) und dem Settings-Schalter selbst. `MeloAudioHandler`,
`NavidromeService` und `SyncService` fragen ihn nie ab.
**Wirkung:** Der Nutzer schaltet "Nur gecachte Lieder abspielen" ein, die App streamt
trotzdem und verbraucht Mobilfunkdaten. Ein Versprechen, das die UI gibt und der Code
bricht.
**Empfehlung:** `OfflineMode` in `loadPlaylist` auslesen. Ist er an, nur Titel mit
Cache-Treffer oder lokalem Pfad in die Queue nehmen und die übersprungenen sichtbar
machen ("3 Titel übersprungen — nicht offline verfügbar").
---
### K2 — Keine `POST_NOTIFICATIONS`-Berechtigung
**Ort:** `android/app/src/main/AndroidManifest.xml`
**Kategorie:** Plattform
Verifiziert im **gemergten** Manifest
(`build/app/intermediates/merged_manifests/release/.../AndroidManifest.xml`):
`POST_NOTIFICATIONS` ist nicht dabei. `targetSdk` folgt Flutter (≥ 33).
**Wirkung:** Auf Android 13+ zeigt das System die Media-Notification erst nach
erteilter Laufzeit-Berechtigung. Ohne sie fehlt die zentrale Bedienfläche einer
Musik-App im Alltag. Der Vordergrunddienst läuft, aber unsichtbar.
**Empfehlung:** Permission ins Manifest, beim ersten Start der Wiedergabe abfragen
(`permission_handler` ist bereits als Abhängigkeit da).
---
### K3 — Server-Titel haben nie ein Coverbild
**Ort:** `lib/shared/cover.dart:38` · `lib/downloads/downloads_screen.dart:309`
**Kategorie:** Visual
`CoverImage` rendert **ausschließlich** `artUri.scheme == 'file'`. Für alles andere
kommt der Noten-Platzhalter. Navidrome-Titel bekommen in `_playAlbum`/`_playArtist`
gar kein `artUri` mitgegeben — obwohl `NavidromeService.coverUrl` (Zeile 265) existiert
und `SubsonicAlbum.coverId` geparst wird.
**Wirkung:** Jeder Server-Titel ist in Liste, Mini-Player, Vollbild-Player, Notification
und Sperrbildschirm ein graues Notensymbol. Das ist der augenfälligste Unterschied zu
Apple Music — dort trägt das Cover die gesamte visuelle Identität.
**Empfehlung:** `CoverImage` um `Image.network` für http/https erweitern (mit
`cacheWidth`, Fehler-Fallback auf den Platzhalter), und in beiden `_play*`-Methoden
`artUri: _nav.coverUrl(s.coverId)` setzen. Bild-Cache lokal ablegen, damit es offline
bleibt.
---
### K4 — ~~Alben fehlen als Browse-Achse~~ → **korrigiert: Album-Detailansicht fehlt**
**Ort:** `lib/library/category_list.dart:12` · `lib/library/categories.dart:26`
**Kategorie:** Funktionsumfang
> **Korrektur vom 2026-08-23 (nach der P0-Runde).** Die ursprüngliche Fassung
> dieses Punktes war falsch. Sie behauptete, die Alben-Achse sei
> „verlorengegangen". Sie wurde **bewusst ersetzt**: `category_list.dart`
> dokumentiert das im Klassenkommentar, und `kategorienAusTags` setzt den
> Album-Namen beim Scan als **erste Kategorie** jedes Titels. Der Reiter
> „Kategorie" *ist* der Alben-Reiter — nur allgemeiner, weil ein Titel
> mehreren Kategorien angehören darf. Der Fehler entstand, weil nur
> `song_grouping.dart` und `my_music_screen.dart` gelesen wurden.
**Was wirklich fehlt**, ist nicht die Achse, sondern die **Ansicht eines
Albums als Album**:
1. **Kein Album-Kopf.** `_KategorieLiederScreen` ist eine `AppBar` über einer
Titelliste. Es fehlen großes Cover, Künstler, Jahr, Titelzahl,
Gesamtdauer und ein „Abspielen / Zufällig"-Paar. Bei Apple Music trägt
genau dieser Kopf die Identität eines Albums.
2. **Falsche Reihenfolge.** Innerhalb einer Kategorie wird nach Titel
sortiert. Ein Album gehört in **Tracknummer-Reihenfolge** — sonst beginnt
jedes Konzeptalbum mit dem Lied, das alphabetisch vorn steht.
3. **Die Tracknummer wird gar nicht gelesen.** Verifiziert: weder `Songs` noch
der Scan kennen `trackNumber`. Punkt 2 braucht also zuerst eine
Schema-Erweiterung plus Auslesen des Tags.
4. **Kein Weg vom Künstler zu seinen Alben.** `_ArtistSongsScreen` zeigt alle
Titel flach.
**Empfehlung (ersetzt die alte):** Keinen vierten Reiter bauen — der würde
denselben Bestand doppelt zeigen. Stattdessen `_KategorieLiederScreen` zu
einem richtigen Detail-Screen ausbauen und die Tracknummer nachrüsten. Der
Aufwand liegt damit **nicht** bei „ein Reiter wie der Künstler-Reiter",
sondern bei Schema-Migration + Scan + Screen.
---
### K5 — Warteschlange neu ordnen startet die Wiedergabe neu
**Ort:** `lib/player/queue_screen.dart:87,103` · `lib/player/audio_handler.dart:169`
**Kategorie:** Usability / Korrektheit
Der Kommentar im Code ist ehrlich: der Handler bietet keine Live-Mutation, also ruft
jedes Entfernen und jedes Verschieben `loadPlaylist()` auf. Das führt
`_player.setAudioSources(...)` + `play()` + `seek()` aus.
Drei Folgen:
1. **Hörbarer Aussetzer** bei jeder Queue-Änderung — bei Apple Music passiert das lautlos.
2. `loadPlaylist` setzt `_lastCountedIndex = null` (Zeile 173) → der laufende Titel wird
**erneut als Wiedergabe gezählt**. Die Sortierung "Wie oft abgespielt" wird durch
Queue-Bedienung verfälscht.
3. Bei Server-Titeln werden alle Cache-Treffer neu ermittelt (`await` in der Schleife).
**Empfehlung:** `removeQueueItem`/`insertQueueItem` auf `_player.removeAudioSourceAt` /
`moveAudioSource` abbilden. `just_audio` kann das ohne Neuaufbau.
---
## Große Punkte (P1)
### G1 — Suche findet nur lokale Titel, nur flach
**Ort:** `lib/library/search_screen.dart` (59 Zeilen)
`db.searchSongs()` per LIKE über Titel/Künstler/Album. Kein Server (`search3.view` wird
nirgends aufgerufen), keine Playlisten, keine Kategorien, keine Gruppierung der Treffer
nach Typ, keine letzten Suchen, keine Vorschläge, kein Entprellen. Apple Music gruppiert
Ergebnisse in Titel/Alben/Künstler/Playlists und zeigt bei leerem Feld die Historie.
Der ganze Tab ist heute ein Textfeld über einer Liste.
### G2 — Navidrome ist im Download-Tab versteckt
**Ort:** `lib/downloads/downloads_screen.dart:56`
Der komplette Server-Browser ist ein Unterreiter von "Download". Ein Nutzer, der seine
Server-Bibliothek hören will, sucht sie nicht unter "Download". Im Referenz-Screenshot
hieß der Tab noch "Online" — treffender.
Dazu: Tippen auf ein Album **spielt sofort ab**. Es gibt keinen Album-Screen, keine
Titelliste, keine Möglichkeit, einen einzelnen Server-Titel zu wählen. Nur 50 Alben,
kein Nachladen, keine Server-Suche.
### G3 — Server-Titel sind Bürger zweiter Klasse
**Ort:** `lib/library/song_media.dart` vs. `lib/downloads/downloads_screen.dart:309`
Ein `MediaItem` vom Server trägt nur `navidromeId`**kein** `songId`. Damit gilt für
Server-Titel: kein Favorit, keine Playlist-Zuordnung, kein lokaler Wiedergabezähler,
keine Kategorie, kein Cover, kein Eintrag in der Historie.
**Konkreter Fehler daraus:** `now_playing_screen.dart:45` gibt
`FavoriteButton(songId: item.extras?['songId'] ?? '')` — bei Server-Titeln also den
**leeren String**. `FavoriteButton` prüft nicht darauf, `toggleFavorite('')` legt eine
Favoriten-Zeile mit leerer ID an. Das Herz füllt sich, in der Favoritenliste (INNER JOIN
auf `songs`) taucht nichts auf. Falsche Rückmeldung an den Nutzer.
### G4 — Kein gezielter Offline-Download
**Ort:** `lib/services/cache_manager.dart` · `lib/player/audio_handler.dart:203`
Gecacht wird nur, was zufällig abgespielt wurde. Es gibt kein "Album offline verfügbar
machen", keine Anzeige, welche Titel offline sind, kein Verwalten pro Titel.
`CacheManager` hat außerdem **keine Größenbegrenzung und keine Verdrängung** — nur
"alles löschen". Der Cache wächst unbegrenzt. Alle Dateien werden `.mp3` benannt,
unabhängig vom tatsächlichen Codec (Navidrome liefert oft Opus/FLAC).
### G5 — Kein "läuft gerade"-Zustand in der Liste
**Ort:** `lib/library/song_list.dart:157`
Die Referenz-UI zeigt den laufenden Titel farbig hervorgehoben mit animierten Balken.
Melos `SongList` hat keinerlei Bezug zum `mediaItem`-Stream. Der Nutzer scrollt durch die
Liste und sieht nicht, wo er gerade ist.
### G6 — Zwei verschiedene Rot-Töne
**Ort:** 10 Vorkommen von `Colors.redAccent` (#FF5252), meist in `settings_screen.dart`
und `downloads_screen.dart`
Die Marke ist `MeloTheme.red` = #C0392B (gedecktes Ziegelrot). `Colors.redAccent` ist ein
grelles Signalrot. Beide stehen im selben Screen nebeneinander. Insgesamt **58
hartcodierte `Colors.*`-Werte** außerhalb des Themes, 18 davon allein in den
Einstellungen — die sind visuell der schwächste Screen der App.
### G7 — Emoji als Icon
**Ort:** `downloads_screen.dart:234,247` (📀 Alben / 🎤 Künstler),
`settings_screen.dart:190` (📴 Offline-Modus), `now_playing_screen.dart:362` (📝 Songtext)
Verstößt gegen die erste Regel der Pre-Delivery-Checkliste von ui-ux-pro-max: Emoji
rendern je nach Hersteller-Font unterschiedlich, lassen sich nicht einfärben, folgen
keinem Design-Token und sind für Screenreader Unsinn. Material-Icons sind vorhanden
(`Icons.album`, `Icons.mic`, `Icons.cloud_off`, `Icons.lyrics`).
### G8 — Songtext ist ein statischer Textblock
**Ort:** `lib/player/now_playing_screen.dart:309`
Der Text wird als eine Wand angezeigt. Kein Zeilen-Sync (LRC), kein Mitlaufen, kein
Antippen zum Springen. Navidromes `getLyricsBySongId` liefert synchronisierte Texte.
Das ist bei Apple Music eine der meistgenutzten Funktionen.
---
### G9 — Nach `stop()` merkt sich die App keine Positionen mehr *(bei der P0-Umsetzung gefunden)*
**Ort:** `lib/player/audio_handler.dart:258` (`stop`)
**Kategorie:** Korrektheit · **Status:** nicht behoben, außerhalb des P0-Auftrags
`stop()` ruft `_positionRecordTimer.cancel()`. Der Timer ist `Timer.periodic`
und wird nur **einmal im Konstruktor** angelegt — einmal abgebrochen, kommt er
für den Rest der App-Sitzung nicht zurück.
`stop()` löst nicht nur der Nutzer aus: das System ruft es beim Wegwischen der
Benachrichtigung und über die Stop-Aktion auf. Danach speichert die App keine
Wiedergabepositionen mehr und scrobbelt nicht mehr zum Server — bis zum
Neustart, und ohne jeden Hinweis. „Fortsetzen an alter Stelle" hört damit
still auf zu funktionieren.
**Empfehlung:** Den Timer in `stop()` nicht abbrechen, sondern in der Callback
prüfen (`if (!_player.playing) return;` steht bereits drin — der Abbruch ist
also gar nicht nötig). Abbrechen gehört allein in `dispose()`.
**Vorbestehend**, nicht durch die P0-Änderungen entstanden: der alte
`QueueScreen` rief in derselben Lage ebenfalls `handler.stop()` auf.
---
### G10 — Server-Cover haben keinen dauerhaften Zwischenspeicher *(Folge der K3-Umsetzung)*
**Ort:** `lib/shared/cover.dart:55`
**Kategorie:** Performance · **Status:** offen, bewusst nicht in P0
`Image.network` legt nur im Arbeitsspeicher ab (Flutters `ImageCache`, ~100 MB,
beim Beenden weg). Beim nächsten Kaltstart fragt jede sichtbare Zeile ihr Cover
erneut beim Server an. Außerdem laufen diese Anfragen auch im Offline-Modus —
sie schlagen dann fehl und fallen auf den Platzhalter zurück, kosten aber
Versuche.
Ein dauerhafter Cache bräuchte entweder `cached_network_image` als neue
Abhängigkeit oder eine Erweiterung des vorhandenen `CacheManager` auf Bilder.
Beides ist eine Entscheidung, keine Fehlerbehebung — deshalb nicht im P0-Fix.
Der Unterschied zu vorher bleibt trotzdem groß: bisher gab es **überhaupt kein**
Server-Cover.
---
## Kleine Punkte (P2)
| # | Punkt | Ort |
|---|---|---|
| P1 | AppBar der Einstellungen heißt **"Settings"** — einziges englisches Wort in einer deutschen App | `settings_screen.dart:65` |
| P2 | Kein `Hero`-Übergang Mini-Player → Vollbild (0 Treffer in `lib/`). Der Wechsel springt, statt das Cover wandern zu lassen | `mini_player.dart:70` |
| P3 | Kein Wischen am Mini-Player (nach unten = schließen, seitlich = nächster Titel) | `mini_player.dart` |
| P4 | Künstlerliste ist eine nackte `ListTile`-Liste ohne Bilder; kein Album-Zwischenschritt | `artist_list.dart:31` |
| P5 | Keine Reduced-Motion-Unterstützung (0 Treffer für `disableAnimations`), obwohl `MeloMotion` sauber getokent ist | `theme.dart:41` |
| P6 | Keine Querformat-/Tablet-Anpassung (`LayoutBuilder` nur in `cover.dart`, `MediaQuery.size` nur im Equalizer) | app-weit |
| P7 | Kein Alphabet-Schnellscroller und keine Mehrfachauswahl in langen Listen | `song_list.dart` |
---
## Vorschläge (P3)
- **V1 — Farbe aus dem Cover ziehen.** Apple Music und Spotify färben den Player nach dem
Albumcover. In Flutter mit `PaletteGenerator` + Verlauf hinter dem Cover. Größter
optischer Sprung pro Zeile Code.
- **V2 — Eigene Schrift.** Die App nutzt System-Roboto. Die ui-ux-pro-max-Abfrage für
"music entertainment" schlägt Righteous/Poppins vor; für Schwarz/Rot passt eher eine
kräftige Grotesk (Outfit, Sora) für Überschriften bei Roboto/Inter im Fließtext.
- **V3 — YouTube-Share-Intent.** Aktuell muss man die URL kopieren und einfügen. Ein
`ACTION_SEND`-Intent-Filter im Manifest macht "Teilen → Melo" möglich. Sehr kleine
Änderung, spürbarer Alltagsgewinn.
- **V4 — Android Auto / Assistant.** Der `MediaBrowserService` ist im Manifest deklariert,
aber `MeloAudioHandler` überschreibt `getChildren`/`getMediaItem` nicht — Browsen im Auto
funktioniert also nicht. Kein Widget, keine Wear-Unterstützung.
---
## Was gut ist (nicht ändern)
- **Design-Token-System** (`theme.dart`): `MeloSpace`, `MeloRadius`, `MeloMotion` mit
begründeten Werten. Die Textfarben sind nicht geschätzt, sondern per
`theme_kontrast_test.dart` gegen 4,5:1 nachgerechnet. Das machen wenige.
- **Haarlinie statt Schatten** auf Schwarz — richtige Entscheidung für OLED, deckt sich mit
der "Dark Mode (OLED)"-Empfehlung des Style-Katalogs.
- **Datenmodell**: UUID + `updatedAtMs` + `deleted`-Tombstone ab Schema 1, 9 saubere
Migrationen. Sync war von Anfang an mitgedacht statt nachgerüstet.
- **Reine Funktionen ausgelagert** (`shouldResumeAt`, `playNextIndex`, `reorderQueue`,
`planeSync`, `newPlayingIndex`) — deshalb 328 Tests ohne Emulator.
- **Kommentare erklären das Warum**, oft mit dem konkreten Bug, der dahinterstand
(`navidromeIdOf`, `cacheSchluessel`, `loadPlaylist`). Genau die Stellen aus
`ui-referenz/bugs/` sind so dokumentiert.
- **Tooltips überall** an Icon-Buttons → in Flutter automatisch Semantics-Label.
---
## Priorisierter Plan
### P0 — bevor irgendetwas Neues gebaut wird
1. `POST_NOTIFICATIONS` ins Manifest + Laufzeit-Abfrage *(K2, ~1 h)*
2. Offline-Modus wirklich verdrahten oder den Schalter entfernen *(K1, ~3 h)*
3. `CoverImage` für http + `artUri` bei Server-Titeln setzen *(K3, ~3 h)*
4. Queue ohne Neuladen mutieren, Doppelzählung beenden *(K5, ~4 h)*
5. Leere `songId` in `FavoriteButton` abfangen *(G3-Teil, ~30 min)*
### P1 — das macht sie "vollwertig"
6. Alben-Achse: `groupByAlbum`, vierter Unterreiter, Album-Screen *(K4, ~1 Tag)*
7. Suche umbauen: nach Typ gruppiert, Server einbeziehen, letzte Suchen *(G1, ~1,5 Tage)*
8. Server-Bereich aus "Download" lösen → eigener Tab "Online" mit Album-Detail *(G2, ~1,5 Tage)*
9. Offline-Download pro Album/Playlist + Cache-Grenze mit LRU *(G4, ~1,5 Tage)*
10. "Läuft gerade"-Markierung in allen Listen *(G5, ~3 h)*
### P2 — Politur, die man sofort sieht
11. `Colors.redAccent``MeloTheme.red`, Emoji → Material-Icons, "Settings" → "Einstellungen" *(G6, G7, P1 — zusammen ~3 h)*
12. Hero-Übergang + Wischgesten am Mini-Player *(P2, P3, ~4 h)*
13. Synchronisierter Songtext (LRC) *(G8, ~1 Tag)*
14. Künstler- und Kategorieliste visuell aufwerten *(P4, ~4 h)*
15. Reduced-Motion + Querformat *(P5, P6, ~4 h)*
### P3 — danach
16. Cover-Farbe im Player *(V1)*
17. Eigene Schrift *(V2)*
18. YouTube-Share-Intent *(V3)*
19. Android Auto: `getChildren` implementieren *(V4)*
---
## Feature-Matrix gegen Apple Music
| Bereich | Apple Music | Melo | Bewertung |
|---|---|---|---|
| Wiedergabe, Hintergrund, Sperrbildschirm | ✅ | ✅ | **da** (Notification-Permission fehlt → K2) |
| Warteschlange ansehen/umordnen | ✅ | ✅ | **schwach** — Neuladen bei jeder Änderung |
| Als Nächstes / Ans Ende | ✅ | ✅ | **da** |
| Shuffle & Repeat | ✅ | ✅ | **da** |
| Sleep-Timer | ✅ | ✅ | **da** (Melo besser als Apple Music) |
| Equalizer | ⚠️ nur System | ✅ 5-Band + Bass + Virtualizer | **besser als Apple Music** |
| Lautstärke-Angleich | ✅ | ✅ ReplayGain | **da** |
| Fortsetzen an alter Stelle | ✅ | ✅ lokal + Server-Bookmark | **da** |
| Favoriten | ✅ | ✅ | **da**, aber nicht für Server-Titel |
| Playlisten anlegen/ordnen | ✅ | ✅ | **da** |
| Alben durchsuchen | ✅ | ❌ | **fehlt** |
| Künstler-Seite mit Alben | ✅ | ⚠️ nackte Titelliste | **schwach** |
| Suche | ✅ typgruppiert, serverweit | ⚠️ lokal, flach | **schwach** |
| Songtext | ✅ zeilensynchron | ⚠️ statischer Block | **schwach** |
| Offline verfügbar machen | ✅ gezielt | ⚠️ nur Zufalls-Cache | **schwach** |
| Cover-Art | ✅ | ⚠️ nur lokale Dateien | **schwach** |
| Geräte-Sync der Bibliothek | ✅ iCloud | ✅ Melo-Cloud + Tombstones | **da** |
| Scrobble / Wiedergabestatistik | ✅ | ✅ lokal + Subsonic | **da** |
| Musikerkennung (Shazam) | ✅ | ✅ ACRCloud | **da** |
| YouTube-Import | ❌ | ✅ | **Alleinstellung** |
| Kategorien / eigene Tags | ❌ | ✅ | **Alleinstellung** |
| Empfehlungen, Radio, Charts | ✅ | ❌ | **fehlt** (bewusst? Melo hat keinen Katalog) |
| Teilen | ✅ | ❌ | **fehlt** |
| Homescreen-Widget | ✅ | ❌ | **fehlt** |
| Android Auto | ✅ | ❌ | **fehlt** (Service da, Browsing nicht) |
| Cast / Sonos | ✅ | ❌ | **fehlt** |
| Crossfade / Gapless | ✅ | ❌ | **fehlt** |
**Zählung:** 13 × da · 7 × schwach · 8 × fehlt · 3 × besser als Apple Music
---
_Erzeugt mit ui-ux-pro-max 2.13.0 (design-system, --stack flutter, pro-rules) und
ui-design:design-review. Alle Code-Stellen im Repo verifiziert, `flutter analyze` und
`flutter test` zum Review-Zeitpunkt ausgeführt._
+29
View File
@@ -28,8 +28,37 @@ den nächsten Bibliotheks-Scan damit überlebt.
| `http.runWithClient` erreicht den State im Modal-Sheet nicht | ❌ widerlegt — eigene Sonde zeigt `MockClient` auch im Sheet |
| drift unter `testWidgets` blockiert (fehlendes `runAsync`) | ❌ widerlegt — `upsertSongs` + `watchSongs().first` laufen durch |
| Einer der Provider blockiert `pumpWidget` | ❌ widerlegt — `MeloDb`, `CategoryService`, `AppSettings` einzeln geprüft |
| `db.close()` im Teardown (der Hänger aus `hauptmenue_test.dart`) | ❌ widerlegt — Entfernen ändert nichts, siehe unten |
| Zusammenspiel `showModalBottomSheet` + Providerbaum | ⏳ offen — der Hänger tritt zwischen `pumpWidget` und dem ersten `tap` auf |
**Nicht die Ursache (2026-08-21 geprüft):** In `hauptmenue_test.dart` trat ein
Hänger mit derselben Signatur auf. Dort war die Ursache `await db.close()`
nach einem `tester.tap` — drift plant beim Schließen eine Aufräum-Aufgabe
ein, die der Test-Rahmen nicht mehr abarbeitet. Das Rezept dagegen:
Dienste per `.value` bereitstellen (der Baum darf sie nicht schließen), am
Testende `pumpWidget(SizedBox())` + `pump(Duration(milliseconds: 1))`, und
auf `db.close()` verzichten. **Bei `song_detail_sheet_test.dart` hilft genau
das nicht** — dort wurde es angewandt und der Test hängt weiter. Die beiden
Hänger haben also unterschiedliche Ursachen.
**Neu geprüft (2026-08-24):** `addTearDown(db.close)` **entfernt** und den
Test entsperrt — er hängt unverändert. `db.close()` ist hier also
ausgeschlossen, nicht nur „hilft nicht".
**Neue Spur aus einem anderen Hänger (2026-08-24).** `auswahl_modus_test.dart`
zeigte dieselbe Signatur: `pumpAndSettle` kam nie zurück, auch nicht mit
gesetzter Zeitgrenze. Ursache dort war, dass **Datenbank und Dienste in
`setUp` entstanden**. `setUp` läuft außerhalb der Testzone: die Ströme von
drift liefen in echter Zeit, während `pumpAndSettle` in Testzeit auf sie
wartete. Aufbau in den Testkörper verschoben → alle fünf Tests laufen in
einer Sekunde durch.
Auf `song_detail_sheet_test.dart` trifft das nicht unmittelbar zu (dort steht
schon alles im Testkörper) — aber `http.runWithClient` legt eine eigene Zone
um den Aufbau. Ob das dieselbe Trennung zwischen echter und Testzeit erzeugt,
ist der nächste lohnende Versuch: den Widget-Baum **vor** `runWithClient`
aufbauen und nur den Netz-Aufruf darin laufen lassen.
**Nächster Schritt:** Den Testkörper schrittweise abspecken (Providerbaum
minimieren, Sheet durch eine normale Route ersetzen) bis der Hänger
verschwindet — die letzte entfernte Zutat ist die Ursache. Ein
+883
View File
@@ -5,6 +5,889 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased]
### 💾❤️ Sync-Ausbau: Favoriten gehen nicht mehr verloren (2026-08-27)
- ❤️ **Der Favoriten-Datenverlust ist behoben.** Bisher hat jedes Gerät beim
Abgleich seine eigene Favoritenliste als Komplett-Ersatz zum Server
geschickt — ein frisch installiertes Handy löschte damit beim allerersten
Abgleich sämtliche Server-Favoriten. Neu wird nur noch **hinzugefügt**:
Was hier Favorit ist und dort fehlt, wird einzeln gemeldet; was dort
Favorit ist und hier fehlt, wird hier gesetzt. Entfernt wird in keiner
Richtung etwas. Der alte Weg (`POST /favorites`) existiert im Code nicht
mehr — der Fehler kann also nicht zurückkommen.
- 💔 **Bewusster Preis:** Ein entferntes Herz wirkt sofort auf diesem Gerät
und (online) auch am Server, ist aber **nicht geräteübergreifend
garantiert**: Hält ein zweites Gerät den Favoriten noch, bringt dessen
nächster Abgleich ihn zurück. Kein Datenverlust ist uns wichtiger als
verlässliches Löschen.
-**Herz antippen meldet sofort.** Wer online ein Herz setzt oder entfernt,
schickt den Wunsch direkt zum Server — eindeutig als „setze auf ja/nein",
nicht als Umschalten. Geht das schief, passiert nichts Schlimmes: der
nächste Abgleich holt es nach.
- 🛡️ **Der Server darf sich nicht mehr missverständlich ausdrücken.** Kommt
auf die Favoriten-Abfrage eine Antwort ohne Favoritenliste, gilt das jetzt
als Fehler und die Favoriten-Runde wird übersprungen — vorher wurde daraus
stillschweigend „keine Favoriten".
- ☁️ **Neu: „Auf den Server laden".** Im Auswahl-Modus von „Meine Musik"
(langes Drücken) lassen sich einzelne Titel markieren und gezielt
hochladen. Titel, die schon oben sind, werden übersprungen; einzelne
Fehlschläge stoppen den Rest nicht. Die Aktion erscheint bewusst nur dort
und nicht bei Favoriten, Wiedergabelisten oder Titellisten.
- ⬇️ **Neu: einzelne Server-Titel offline nehmen.** In der Album- und
Künstler-Ansicht hat jede Zeile jetzt einen Knopf — bisher ging nur „ganzes
Album". Schon geladene Titel lassen sich dort auch wieder entfernen.
*Bekannte Einschränkung:* Ein gerade gehörter Titel liegt danach kurzzeitig
doppelt (Zwischenspeicher + Download), bis der Zwischenspeicher aufräumt.
- 🗂️ **Playlisten werden einseitig am Server gesichert** (kein Rück-Merge,
Umbenennungen propagieren nicht).
- 📰 **Neu: „Willkommen zurück".** War der letzte **erfolgreiche** Abgleich
mehr als 24 Stunden her, zeigt die App danach einmalig, was dazugekommen
und was verschwunden ist. Beim allerersten Start nach einer Neuinstallation
erscheint er absichtlich **nicht**.
- 🔧 **Unter der Haube:** Der Abgleichs-Zeitstempel wird jetzt **vor** dem
Abfragen der Serverliste genommen (sonst fallen Änderungen während des Laufs
durchs Raster); ein hängender Upload (Zeitüberschreitung) reißt nicht mehr
den ganzen Abgleich ab; ausgefallene Teilschritte verschieben die
24-Stunden-Uhr des Berichts nicht mehr.
- 🧪 Neue Tests für die Merge-Regeln, den additiven Abgleich, den Sofort-Push,
den Auswahl-Upload samt Abbrechen und den Bericht. Zwei bestehende Tests
wurden bewusst umgeschrieben, weil sie das alte (fehlerhafte) Verhalten
festschrieben.
### 📺🔑 YouTube-Tab: Server-User brauchen keine eigene Baka-Anmeldung mehr (2026-08-26)
- 🔑 **Server-User (Dustin, Baka, Tinker) sehen den Baka-Anmelde-Schritt nicht
mehr.** Bisher verlangte der YouTube-Downloader/die YT-Suche IMMER eine
eigene, separate Anmeldung am Baka-Konto — unabhängig davon, ob man schon
am Melo-Server angemeldet war. Neu: Wer mit dem Melo-Server verbunden ist
(`NavidromeService.istVerbunden`), meldet sich beim Öffnen des
YouTube-Bereichs automatisch im Hintergrund mit denselben Zugangsdaten
beim Baka-Konto an (`BakaAuth.autoAnmelden`, neu in `baka_auth.dart`) —
kein Dialog, kein Warten. Voraussetzung: Navidrome- und Baka-Konto teilen
sich Benutzername + Passwort (so bei Dustin/Baka/Tinker der Fall).
Schlägt die Hintergrund-Anmeldung fehl (z. B. abweichendes Passwort),
bekommt der Server-User den bisherigen manuellen Anmelde-Weg als
Rückfalloption zurück — sonst gäbe es keinen Weg mehr zu `_AnmeldeDialog`,
das war ein im @code-review-Fund entdeckter Aussperr-Bug (siehe
`pruefeServerUser` in `baka_auth.dart`).
- 👋 **Die Anmeldung ist jetzt wirklich nur noch für Gäste sichtbar.** Ohne
Server-Verbindung (kein eigener Melo-Account) erscheint weiterhin der
Hinweistext + Anmelde-Button wie bisher — Server-User sehen stattdessen
direkt das Adressfeld, ganz ohne Konto-Status-Zeile.
- 🎨 **Login-UI vergrößert.** "Beim Baka-Konto anmelden" ist jetzt ein
Button über die volle Breite (52 px hoch, größere Schrift); die Buttons
"Abbrechen"/"Anmelden" im Anmelde-Dialog haben jetzt eine Mindesthöhe von
48 px. Der Dialog hatte ohnehin nie ein Adressfeld — die Proxy-URL bleibt
fest im Code hinterlegt, nicht sichtbar oder änderbar.
-**Getestet:** 582 Tests grün (vorher 574), `flutter analyze` ohne
Befund. 8 neue Tests: `BakaAuth.autoAnmelden` (meldet an, greift nicht
ein wenn schon angemeldet, tut nichts ohne Zugangsdaten) sowie
Widget-Tests für den Server-User-Weg und den Rückfallweg bei
fehlgeschlagener Auto-Anmeldung in Download-Tab und YT-Suche-Tab.
### 🎚️📺 Equalizer-Fix (Xiaomi) + In-App-YouTube-Suche, Extra-Tab (2026-08-25)
- 🎚️ **Equalizer: "kein System-Klangeffekte" trotz Dolby Atmos behoben.**
`openSystemPanel()` nutzte bisher nur den Android-Standard-Intent
(`ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL`) — dafür registriert
MIUI/HyperOS auf dem POCO X7 Pro keine Activity, `resolveActivity()`
liefert dort immer `null`. Neu: zuerst der Xiaomi-Weg direkt zur
System-Ton-Seite (`com.android.settings.Settings$SoundSettingsActivity`,
wo Dolby Atmos liegt), danach `Settings.ACTION_SOUND_SETTINGS` als
universeller Fallback, zuletzt weiter der Android-Standard-Intent für
Geräte, die den unterstützen. Kotlin kompiliert sauber
(`gradlew :app:compileDebugKotlin`); echtes Testen auf einem POCO X7 Pro
war von hier aus nicht möglich.
- 📺 **In-App-YouTube-Suche — neuer Extra-Tab "YT-Suche" (zum Testen).**
Bisher ließ sich nur eine YouTube-Adresse einfügen; jetzt gibt es eine
echte Suche mit Ergebnisliste (Vorschaubild, Titel, Dauer) und
Download-Knopf pro Treffer. Nutzt den bereits vorhandenen
`/api/search`-Endpunkt des Baka-Proxys (yt-dlp-gestützt, dieselbe
Anmeldung wie der bestehende YouTube-Downloader) — kein neuer Server,
kein YouTube-API-Key nötig. Die Vorschaubilder kommen direkt von
YouTubes öffentlichem Thumbnail-CDN, aus der Video-ID der Ergebnis-URL.
Der Download-Knopf läuft über denselben Weg wie im Download-Tab
(Baka-Proxy → App-Zwischenordner → MediaStore → Bibliotheks-Scan).
- 🐛 **Beim Testen gefunden:** `YtDownloadService.herunterladen()`
(Future.any + `.timeout()`) lässt sich nicht sicher aus einem
`testWidgets()`-Test heraus aufrufen — der Timeout-Timer bleibt unter
Flutters `fake_async`-Testbindung dauerhaft "pending" und lässt den Lauf
hängen bzw. an der Pending-Timer-Prüfung scheitern (reproduziert auch
ganz ohne die neue UI). Genau deshalb hat auch der bestehende
Download-Knopf im Download-Tab keinen eigenen Widget-Test — die
Download-Logik selbst ist über `yt_download_test.dart` schon ausführlich
abgedeckt, an `YtDownloadService` wurde nichts geändert.
-**Getestet:** 574 Tests grün (vorher 557), `flutter analyze` ohne
Befund. Neu: Tests für `youtubeVideoId`/Thumbnail-Ableitung, für
`YtSearchService.suchen` (Anmeldung, Erfolg, 401, Fehlertext, Timeout,
neue Suche ersetzt alte Treffer) und zwei Widget-Tests für den neuen Tab
(Anmelde-Hinweis, Suchergebnisse mit Titel/Dauer).
### 🔒 Worktree-Lock gegen Doppelarbeit (2026-08-25)
Aus der Workflow-Analyse desselben Tages (Dustin↔Hermes↔Claude, siehe
Eintrag unten): zwei Sessions bekamen 11 Minuten auseinander denselben
Auftrag für diesen Worktree — die erste committete nicht rechtzeitig, die
zweite musste die fertige Arbeit nachträglich unversioniert einsammeln.
- `melo-app-workflow`-Skill (nicht Teil dieses Repos) hat jetzt eine
Pflicht-Regel: vor der ersten Änderung eine `.claude-worktree-lock`-Datei
im Worktree-Root auf Frische prüfen (< 60 Minuten = vermutlich aktiv →
erst Dustin fragen), sonst eigene Sperre setzen und am Ende entfernen.
- `.claude-worktree-lock` neu in `.gitignore` — lokaler Laufzeit-Zustand,
kein Projektstand.
- Test-Timeout-Vorgabe im Skill korrigiert: `flutter test` dauert bei 550+
Tests oft >400s: Hintergrund-Lauf statt Foreground-Timeout ist jetzt der
dokumentierte Standard.
- Kein Code geändert, nur `.gitignore` — kein Testlauf nötig.
### 🔐 Berechtigungen beim Start, schärferes Sperrbildschirm-Cover (2026-08-25)
Vier Punkte aus dem Auftrag geprüft — zwei davon waren schon erledigt:
-**Tab-Reihenfolge** (Meine Musik, Suchen, Download, Favoriten) und
**konfigurierbare Schnellzugriffe in "Meine Musik"** (Checkboxen in
den Einstellungen) waren bereits vollständig umgesetzt — nichts zu tun,
siehe Test `home_shell_test.dart` bzw. `AppSettings.meineMusikSektionen`.
- 🔑 **Alle Berechtigungen beim ersten Start.** Bisher fragte die App das
Musik-Recht erst beim manuellen Scannen und das Benachrichtigungs-Recht
erst nach dem ersten abgespielten Titel — beides isoliert und spät.
`LibraryService.ensureStartupPermissions()` fragt jetzt gleich beim
App-Start beide Rechte nacheinander ab (nicht gleichzeitig —
`permission_handler` wirft sonst einen Fehler). Wird eines dauerhaft
verweigert, zeigt "Meine Musik" ein Hinweisbanner mit Button direkt zu
den App-Einstellungen (`openAppSettings`) — für die Benachrichtigung neu,
fürs Musik-Recht gab es das schon. Auch in den Einstellungen selbst gibt
es jetzt für beide Rechte eine eigene Zeile mit "Einstellungen öffnen".
- 🖼️ **Schärferes Cover für den Sperrbildschirm.** `audio_service` liefert
das Album-Cover fürs große Sperrbildschirm-Bild (MIUI & Co.) technisch
schon über die MediaSession — die extrahierte Auflösung beim
Android-Scan war mit 512px aber knapp. Jetzt 1024px.
- 🐛 **Dabei gefunden:** Der neue Start-Berechtigungs-Aufruf ließ einen
bestehenden Widget-Test (`home_shell_test.dart`) hängen — ein
unbedingter `notifyListeners()`-Aufruf ohne echte Wertänderung kollidierte
mit dessen Test-Teardown (nur ein `pump()`, kein `pumpAndSettle()`).
`LibraryService` meldet Änderungen an `permissionDenied` und
`notificationPermissionDenied` jetzt nur noch, wenn sich der Wert
tatsächlich ändert.
-**Getestet:** 557 Tests grün (vorher 550), `flutter analyze` ohne
Befund. Neu: Banner-Test für die verweigerte Benachrichtigung, Test für
die neue Berechtigungs-Zeile in den Einstellungen.
### 🧑‍🤝‍🧑 UX-Simulation, Teil 3: die zurückgestellten Punkte (2026-08-25)
Die vier Punkte aus Teil 2, die als "eigenes kleines Vorhaben" zurückgestellt
waren, sind jetzt auch erledigt:
- ⏹️ **Abbrechen-Knopf für YouTube-Downloads.** Ein hängender Download (bis
zu zwei mal 180s) ließ sich vorher nur durch Schließen der App stoppen.
`YtDownloadService` wartet jetzt mit `Future.any` gegen ein Abbruch-Signal
— die Netzwerk-Anfrage läuft im Hintergrund aus, die App wartet aber
nicht mehr darauf und meldet sich sofort zurück.
- 🔴 **Punkt am inaktiven Download-Unterreiter,** wenn dort im Hintergrund
ein Download läuft — man sah vorher nicht, dass beim Wechsel zu
YouTube/Server nebenan noch etwas passiert. `SubTabs` kann das jetzt für
jeden Reiter einzeln (neuer, optionaler `badges`-Parameter).
- 🔢 **Versionsnummer unter "Über Melo"** — dafür kam `package_info_plus`
als neue Abhängigkeit dazu (federated plugin, kein zusätzliches
Manifest nötig).
- 👆 **Mini-Player folgt jetzt dem Finger.** Vorher zählte nur ein kräftiger
Schwung (≥ 300 px/s); wer langsam, aber weit genug wischte, bekam gar
keine Reaktion — wie ein hängender Bildschirm statt "zu langsam
gewischt". Der Player führt die Bewegung jetzt sichtbar mit
(`AnimatedContainer.transform`) und entscheidet beim Loslassen über
Schwung **oder** zurückgelegte Strecke; ohne Wirkung schnappt er sichtbar
zurück. Nach unten bleibt weiterhin unbelegt (Absicht, unverändert).
- 🔧 **Nebenbei gefunden:** Drei bestehende Widget-Tests
(`hauptmenue_test.dart`, `home_shell_test.dart`,
`online_screen_test.dart`) bauten den Download-Tab ohne
`Provider<DownloadService>` auf — funktionierte bisher nur, weil niemand
ihn dort abfragte. Das Badge tut das jetzt app-weit; die drei
Test-Aufbauten sind entsprechend ergänzt.
-**Getestet:** 550 Tests grün (vorher 539), `flutter analyze` ohne
Befund. Neu: Abbruch-Test für den YouTube-Download (hängende Anfrage per
`Completer`), zwei Tests für das SubTabs-Badge, vier für die
streckenbasierten Wisch-Funktionen.
### 🧑‍🤝‍🧑 UX-Simulation, Teil 2: Player-Feinschliff, Downloads, Einstellungen (2026-08-25)
Zweiter Block derselben 39 Funde:
- 🎧 **Songtext-Icon ohne ID war ein toter Knopf** — sichtbar, tippbar, aber
ohne jede Wirkung. Jetzt ausgegraut mit erklärendem Tooltip, wenn kein
Songtext-Ziel bekannt ist.
- ⏱️ **Sleep-Timer-Restzeit war faktisch unsichtbar** (nur im Tooltip, den
kaum jemand per Long-Press entdeckt) — steht jetzt auch im Sheet.
- ↕️ **Warteschlange umsortieren war unauffindbar:** kein sichtbares Zeichen,
dass die Liste per Drag reagiert. Jetzt mit Drag-Handle-Symbol wie bei
Spotify/Apple Music.
- 👇 **Vollbild-Player ließ sich nicht wegwischen** — nur über den
Zurück-Pfeil schließbar, obwohl der Mini-Player schon Wischgesten kann.
Jetzt schließt Wischen nach unten den Player, wie bei Apple Music/Spotify.
- 🏷️ Fehlendes `tooltip:` am Schließen-Knopf des Songtext-Sheets ergänzt —
jeder andere Icon-Button der App hat einen.
- 📡 **YouTube-Downloads:** Zeitüberschreitung (Server erreichbar, braucht
nur zu lange) und toter Server meldeten sich bisher identisch — jetzt mit
eigener, ehrlicherer Meldung ("braucht zu lange" statt "nicht
erreichbar").
- 🔄 **Server-Tab blitzte beim Öffnen kurz "nicht verbunden" auf**, obwohl
verbunden — weil `build()` lief, bevor die gespeicherten Zugangsdaten
fertig geladen waren. Jetzt erst ein Ladezustand, dann die echte Antwort.
- 🗂️ **Einstellungen umsortiert:** Favoriten-Sync und Playlisten-Sync
standen unter "Speicher", obwohl sie nichts mit Speicherplatz zu tun
haben — jetzt bei "Musikserver", direkt beim Navidrome-Eintrag. Der
Navidrome-Eintrag selbst sagt jetzt, wofür er da ist ("Eigener
Musik-Server"), statt nur den Software-Namen zu zeigen.
- ✏️ Kommentar in `geteilte_adresse.dart` korrigiert: er behauptete, der
Server entscheide über nicht-YouTube-Adressen — tatsächlich lehnt
`YtDownloadService` sie schon vorher ab. Verhalten bewusst nicht
geändert (dafür gibt es einen eigenen Test), nur die Doku richtiggestellt.
- ⏸️ **Bewusst nicht angefasst:** das separate Server-Favoriten-Herz
(`ServerFavoriteButton`) bleibt wie dokumentiert lokal-only — das ist eine
bestehende, begründete Architektur-Entscheidung (siehe G3 im
UX-Review) und keine Kleinigkeit, die sich nebenbei mitreparieren lässt.
Ebenso zurückgestellt: Live-Mitführen des Fingers beim langsamen Wischen
am Mini-Player, ein Abbrechen-Knopf für laufende YouTube-Downloads, ein
Lauf-Badge auf dem inaktiven Download-Unterreiter und eine Versionsnummer
unter "Über Melo" (bräuchte eine neue Abhängigkeit) — alles eigene
kleine Vorhaben, keine Fünf-Minuten-Fixe.
-**Getestet:** 539 Tests grün (vorher 537, 2 neu für die
Timeout-Unterscheidung beim YouTube-Download), `flutter analyze` ohne
Befund.
### 🧑‍🤝‍🧑 UX-Simulation: Mehrfachauswahl, Baka-Login, Bestätigungen, Suche (2026-08-25)
7 Agenten sind die App wie ein normaler Nutzer durchgegangen (Erststart,
Bibliothek, Download, Suche, Favoriten, Player, Einstellungen) und haben 39
konkrete Reibungspunkte gefunden — verglichen mit Apple Music/Spotify. Erster
Block der Fixe:
- 🖐️ **Mehrfachauswahl funktionierte nur im Lieder- und Favoriten-Reiter.**
Öffnete man eine Kategorie oder das Album eines Künstlers, tat langes
Drücken auf einen Titel gar nichts — derselbe Ripple-Effekt, aber ohne
Wirkung. Die Auswahl-Leiste und die Sammel-Aktionen ("Zur Warteschlange",
"Zu Wiedergabeliste") aus `SortableSongList` sind jetzt in
`lib/shared/auswahl_leiste.dart` geteilt und laufen auch in der
Album-/Kategorie-Detailansicht (`TitelListenScreen`, jetzt mit Sortierung
und Shuffle wie der Lieder-Reiter) und in der Künstlerseite
(`KuenstlerScreen`).
- 🔁 **Zwei "Favoriten"-Listen mit unterschiedlichem Funktionsumfang.** Der
Favoriten-Tab bot Sortieren/Shuffle/Auswahl, der über Playlisten
erreichbare zweite Favoriten-Bildschirm zeigte dieselben Songs nur nackt.
Beide zeigen jetzt dieselbe `SortableSongList`.
- 🔐 **Baka-Login blieb bei falschem Passwort bis zu 15 Sekunden lang
stumm.** Der Dialog schloss sich sofort beim Antippen von "Anmelden",
noch bevor die Anfrage überhaupt losging; der Fehler kam erst danach in
einer unauffälligen grauen Zeile. Jetzt bleibt der Dialog offen, zeigt
einen Ladespinner und den Fehler direkt darin — wie beim (bereits guten)
Navidrome-Login.
- 🗑️ **Cache leeren und Navidrome-Abmelden liefen ohne Rückfrage.** Ein
versehentlicher Tipp auf das kleine Papierkorb- bzw. Logout-Icon hat
sofort gewirkt. Beide fragen jetzt nach, das einzelne Entfernen eines
Downloads ebenso (analog zum bereits vorhandenen "Alle entfernen"-Dialog).
- 📡 **Downloads bei toter Serververbindung meldeten "War schon
heruntergeladen"** — dieselbe Meldung wie bei tatsächlich schon
vorhandenen Titeln. Jetzt erscheint der wirkliche Grund
("Nicht mit dem Musikserver verbunden"). YouTube-Fehlermeldungen sind
außerdem jetzt rot statt in derselben Neutralfarbe wie Erfolgsmeldungen.
- 🔍 **Suche:** Die Tastatur blieb nach Auswahl eines Treffers offen und
verdeckte die Liste — schließt jetzt automatisch. "Nichts gefunden" nennt
jetzt den gesuchten Begriff. Einzelne Verlaufseinträge lassen sich
entfernen, ohne den ganzen Suchverlauf zu löschen.
- 🎤 **Zwei irreführende Icons:** "Musik-Berechtigung" in den Einstellungen
zeigte ein Mikrofon (fragt aber Speicherzugriff ab, kein Mikrofon);
"Musik erkennen" zeigte ein Fragezeichen-Symbol (Hilfe-Icon) statt eines
erkennbaren Mikrofon-Symbols.
- ✏️ Kleinere Textkorrekturen: der Hinweis "beim Baka-Konto anmelden" verwies
auf einen nicht existierenden Tab "Online" (richtig: Download-Tab); die
ReplayGain-Beschreibung nannte unerklärten Audio-Fachjargon.
-**Getestet:** 537 Tests grün (vorher 533, 4 neu für `verlaufOhne`),
`flutter analyze` ohne Befund.
### 🎨 Cover-Farbe, Teilen, Android Auto, geschärfte Typografie (2026-08-24)
Die P3-Liste aus dem Review.
- 🌈 **Der Player nimmt die Farbe des Covers auf.** Bisher war er für jeden
Titel gleich schwarz; jetzt läuft ein Verlauf aus der prägenden Farbe des
Albums nach unten in Schwarz aus. **Ohne neue Abhängigkeit:** die
Farbwahl ist eine reine Funktion über die Bildpunkte und dadurch prüfbar.
Nicht einfach die häufigste Farbe — Cover sind oft überwiegend dunkel oder
grau, und dann sähe wieder alles gleich aus. Häufigkeit **und** Farbigkeit
entscheiden zusammen. Anschließend wird so weit abgedunkelt, dass weiße
Schrift darauf lesbar bleibt.
- 📤 **„Teilen → Melo" aus der YouTube-App.** Vorher musste man die Adresse
von Hand kopieren und einfügen. Die App springt in den Download-Bereich und
trägt die Adresse ein — **ohne** den Download zu starten: Kategorie und
Speicherort will man vorher noch wählen können. Die geteilte Nachricht
enthält meist Titel und „über @YouTube" drumherum; daraus wird die Adresse
herausgelöst. Bewusst nicht auf YouTube eingeschränkt — der Downloader
läuft über yt-dlp und kann mehr, und was davon geht, entscheidet der Server.
- 🚗 **Android Auto blättert jetzt wirklich durch die Bibliothek.** Der
MediaBrowserService war im Manifest zwar angemeldet, lieferte aber nie
Inhalte — die Bibliothek blieb dort leer. Vier Ordner wie in der App:
Favoriten · Zuletzt hinzugefügt · Kategorien · Künstler. Ein Titel im Auto
startet den **ganzen Ordner** ab dieser Stelle, nicht nur das eine Lied.
Die Adressen halten Doppelpunkte und Schrägstriche aus — „Intro: Anfang"
und „AC/DC" sind normale Namen.
- 🔠 **Typografie geschärft, ohne neue Schrift.** Melo bleibt bei der
System-Schrift: sie ist immer da, trägt jede Sprache und kostet nichts an
App-Größe. Geschärft wurde vor allem die **Laufweite** — große Grade stehen
enger, damit die Zeile als ein Wortbild liest; Abschnitts-Beschriftungen in
Großbuchstaben dagegen weiter, weil die Luft brauchen.
- 🐛 **Dabei gefunden:** Die drei Bildschirm-Überschriften („Download",
„Favoriten", „Suchen") setzten ihre Größe **von Hand**, während das Theme
eine eigene Stufe dafür vorhielt — die nie benutzt wurde. Eine Änderung am
Theme wäre nirgends angekommen. Jetzt gehen alle drei durch eine gemeinsame
Überschrift.
- ↩️ **Und eine Rücknahme:** Ich hatte den Fließtext dichter gesetzt und damit
eine Regel gebrochen, die seit Längerem als Test festgehalten ist
(„Fließtext hat mindestens 1,35 Zeilenhöhe"). Die Regel ist älter als diese
Politur und hat einen Grund — zurückgenommen wurde deshalb mein Code, nicht
der Test.
-**Getestet:** 533 Tests grün (vorher 500), `flutter analyze` ohne Befund,
Release-APK gebaut; Auto-Eintrag und Teilen-Filter im fertigen APK
nachgeprüft. Neu: 9 Tests für die Cover-Farbe, 8 für das Herauslösen der
geteilten Adresse, 9 für die Adressen des Auto-Baums und 7 für die
Stufenleiter der Schrift.
### ✨ Politur: Wischen, wandernde Cover, Alphabet-Sprung, Mehrfachauswahl (2026-08-24)
Die P2-Liste aus dem Review — lauter Kleinigkeiten, die man einzeln nicht
vermisst und zusammen sofort merkt.
- 🎞️ **Das Coverbild wandert.** Beim Öffnen der Vollbild-Wiedergabe springt
kein neuer Bildschirm auf; das Bild aus der Leiste wächst an seinen Platz.
- 👆 **Wischen am Mini-Player.** Nach oben öffnet die Wiedergabe, seitwärts
wechselt den Titel. Mit Schwelle: der Mini-Player ist zugleich ein Knopf,
und beim Antippen wandert der Finger fast immer ein paar Pixel — ohne
Schwelle spränge bei jedem zweiten Tipp der Titel. Nach unten ist bewusst
unbelegt, damit niemand die Wiedergabe aus Versehen wegschiebt.
- 🎨 **Die Künstlerliste hat Bilder** und nennt die Zahl der Alben. Dahinter
jetzt eine Künstlerseite mit seinen Alben, darunter alle Titel — vorher
stand bei einem Künstler mit acht Alben alles ungeordnet untereinander.
Bei nur einem Album entfällt die Zwischenstufe.
- 🔤 **Alphabet-Sprung am rechten Rand.** Erscheint nur, wo er stimmt: bei
Sortierung nach Namen und ab 30 Titeln. Bei „Zuletzt hinzugefügt" stünden
die Buchstaben in willkürlicher Reihenfolge. Umlaute zählen zu ihrem
Grundbuchstaben, Ziffern und Zeichen sammeln sich unter „#" — dieselbe
Regel wie die Sortierung selbst.
- ☑️ **Mehrfachauswahl.** Langes Drücken wählt aus, danach wählt jeder Tipp
weitere Titel. Oben stehen „Zur Warteschlange" und „Zu Wiedergabeliste".
Wer zwanzig Lieder in eine Liste wollte, tippte vorher sechzig Mal.
-**Abgeschaltete Systemanimationen gelten jetzt überall.** Bisher hielt
sich nur die Balken-Animation daran; Reiterwechsel, Songtext und
Mini-Player zogen weiter durch.
- 📱 **Querformat und Tablet.** Der Player legt Cover und Bedienung
nebeneinander, statt vom Cover einen Streifen übrig zu lassen oder die
Knöpfe aus dem Bild zu schieben. Auf breiten Schirmen bekommt der Text eine
Obergrenze, statt über die ganze Breite zu zerren.
- 🐛 **Beim Bauen gefunden:** Der Alphabet-Sprung landete bei absteigender
Sortierung am **Ende** eines Buchstaben-Blocks statt an seinem Anfang. Der
Index wird jetzt direkt auf der angezeigten Reihenfolge gebaut, mit Test
für beide Richtungen.
- 🔍 **Und ein hängender Test aufgeklärt.** Der neue Auswahl-Test kam nie
zurück — dieselbe Signatur wie der seit dem 21.8. übersprungene Test im
BACKLOG. Ursache: **Aufbau in `setUp`.** Das läuft außerhalb der Testzone,
also liefen Datenbank und Dienste in echter Zeit, während der Test in
Testzeit auf sie wartete. Aufbau in den Testkörper verschoben → fünf Tests
in einer Sekunde. Der alte Fall im BACKLOG hat eine andere Ursache
(nachgeprüft: `db.close()` ist es dort nicht), aber die Spur ist dort
notiert.
- 🔍 **Aus dem Code-Review nachgebessert — sieben Punkte, einer davon schwer:**
**(1)** Die Alphabet-Leiste wurde **breiter, je weniger Buchstaben** sie
hatte. Jeder Buchstabe skalierte auf die volle Höhe seines Platzes. Bei
einem Album mit 40 Titeln „Track 01…40" gäbe es genau einen Buchstaben —
die Leiste wäre 528 dp breit geworden und die Songliste vollständig
verschwunden. Feste Breite jetzt, und ab drei verschiedenen Buchstaben
überhaupt erst sichtbar.
**(2)** Die neue Künstlerseite baute **alle** Titel sofort. Jede Zeile hört
auf den Player und öffnet für ihr Herz eine eigene Abfrage — bei einem
Künstler mit 300 Titeln wären das 300 Abos beim Öffnen. Vorher war die
Liste faul; das war ein Rückschritt von mir.
**(3)** Wer Systemanimationen abgeschaltet hat, hätte beim mitlaufenden
Songtext einen Absturz bekommen: die Bildlauf-Animation verträgt keine
Dauer null. Jetzt wird gesprungen — das ist ohnehin, was die Einstellung
meint.
**(4)** Die feste Zeilenhöhe war mit 64 zu klein (ein `ListTile` mit
Untertitel ist 72). Sobald der Schnellzugriff aktiv wurde, schrumpften alle
Zeilen sichtbar gegenüber jeder anderen Liste. Außerdem wird die
Schriftskalierung jetzt an einer echten Schriftgröße gemessen statt an 1.0
— Android skaliert seit Version 14 nichtlinear.
**(5)** Titel, die schon in der Zielliste waren, wurden **verschoben** statt
übersprungen, und die Meldung zählte sie trotzdem mit. Jetzt: „7 hinzugefügt
· 3 waren schon drin".
**(6)** Ein Fehler mitten im Stapel brach still ab. Jetzt wird gesagt, wie
weit es kam.
**(7)** Die Zurück-Geste verließ den Bildschirm, statt die Auswahl zu
beenden — zwanzig markierte Titel mit einer Geste weg, die überall sonst
„einen Schritt zurück" heißt.
- 🧹 **Selbst gefunden:** In der Künstlerliste hing der Datenbank-Strom an
`CategoryService` und wurde bei **jeder** Kategorie-Meldung neu aufgesetzt
— während eines Scans also laufend. Derselbe Fehler, den der Review mir
eine Runde zuvor bei der Suche gezeigt hatte.
-**Getestet:** 500 Tests grün (vorher 457), `flutter analyze` ohne Befund,
Release-APK gebaut. Neu: 8 Tests für die Wisch-Schwellen, 10 für den
Alphabet-Index, 5 für die Breite der Leiste (inklusive des Falls mit einem
einzigen Buchstaben), 11 für die Auswahl-Regeln und Meldungen, 5
Widget-Tests für den Auswahl-Modus, 3 für die Album-Gruppierung und 3 für
die Künstler-Zeile.
### 🧩 Die letzten vier Punkte aus dem Review (2026-08-23)
**G2 — Server-Bereich.** Ein Tipp auf ein Album spielte sofort das ganze
Album ab; hineinsehen ging nicht.
- 💿 **Album- und Künstler-Ansicht.** Großes Cover, Künstler, Titelzahl,
Gesamtdauer, „Abspielen" und „Zufällig" — darunter die Titelliste mit
Nummer und Dauer. Antippen startet **an dieser Stelle**, nicht am Anfang.
Der laufende Titel ist auch hier markiert, und schon geladene Titel tragen
ein Häkchen.
- 📄 **Mehr als 50 Alben.** Die Liste hörte bei 50 auf, ohne dass etwas
darauf hinwies. Jetzt gibt es „Mehr Alben laden", und wenn der Server
weniger als eine volle Seite liefert, verschwindet der Knopf von selbst.
- 🚫 **Nicht umgesetzt:** „Server-Bereich aus dem Download-Tab lösen" stand
auch im Befund — das widerspricht aber Commit `34d6d8e` („Auf Wunsch von
Dustin"), mit dem der Tab bewusst wieder „Download" heißt. Die
Tab-Struktur bleibt unangetastet. Ebenso weggelassen: eine Suche über den
Server, weil die Suche ausdrücklich rein lokal bleiben soll.
**G8 — Songtext läuft mit.** Bisher eine Textwand ohne Bezug zur Wiedergabe.
- 🎤 Trägt der Text Zeitmarken (LRC), läuft er jetzt **mit**: die aktuelle
Zeile ist hell und größer, der Rest gedämpft, und die Ansicht schiebt sie
in die Mitte. **Antippen springt an die Stelle im Lied.**
- 📃 Ein Text ohne Zeitmarken bleibt eine Textwand — beides kommt vor, je
nach Tag und Server, und die App schaut nach, statt zu raten.
**G10 — Cover bleiben jetzt da.** `Image.network` merkt sich Bilder nur im
Arbeitsspeicher; nach jedem Neustart fragte jede Zeile ihr Cover erneut beim
Server an, und ohne Netz gab es gar keins.
- 🖼️ Cover landen in einem eigenen Ordner und werden nur einmal geholt. Der
Schlüssel hängt an der Album-Kennung, **nicht** an der Adresse — die trägt
bei jedem App-Start ein neues Token, und der Ordner wäre sonst gewachsen,
ohne je einen Treffer zu liefern.
- 🧹 Bewusst im Cache-Ordner: ein Cover ist ersetzbar. Räumt Android auf,
lädt es sich beim nächsten Mal neu — anders als die Musik-Downloads.
- ⚠️ Nur echte Bilder werden abgelegt: Subsonic meldet Fehler mit HTTP 200
und JSON, und das läge sonst dauerhaft als „Cover" im Ordner.
**Aus dem Code-Review nachgebessert — sieben Punkte:**
- 🤍 **Das Herz behielt beim Titelwechsel seinen Zustand.** Einen Server-Titel
favorisieren, der Player geht zum nächsten — und der zeigte sich als
Favorit, obwohl er nie einer war. Der nächste Tipp hätte ihn dann
„entfavorisiert".
- 🖼️ **Beim Scrollen zeigten Zeilen kurz das Cover der vorherigen.** Listen
verwenden ihre Zeilen wieder; ohne Schlüssel behielt die Bildabfrage das
alte Ergebnis, bis das neue da war — bei ungecachten Covern sekundenlang.
Ein Rückschritt gegenüber dem vorherigen Stand, deshalb hier mitbehoben.
- 🎤 **Der neue mitlaufende Songtext funktionierte für Server-Titel gar
nicht.** Ihm wurde die Abspiel-Adresse statt der Server-Kennung
übergeben; die App fragte den Server nach einem Titel namens „https://…"
und bekam nie einen Text.
- 🎨 **Ein Cover, das einmal nicht lud, blieb die ganze Sitzung grau.** Startet
die App ohne Netz, waren alle Cover bis zum Neustart weg — auch wenn die
Verbindung längst wieder stand. Fehlschläge werden nicht mehr gemerkt.
- 🚨 **„Server nicht erreichbar" sah aus wie „Album hat keine Titel".** Beide
Abfragen verschluckten ihre Fehler und lieferten eine leere Liste; der
Wiederholen-Knopf, den ich gebaut hatte, war damit unerreichbar.
- 🎸 **Die Künstler-Ansicht wäre immer leer geblieben.** Die App las die Titel
aus `getArtist`, wo die Subsonic-Schnittstelle sie gar nicht mitliefert —
Alben kommen dort ohne ihre Titel. Jetzt wird je Album nachgefragt.
(Vorbestehend, aber ich habe einen ganzen Bildschirm darauf gebaut.)
- 🧩 Dazu ein seltener, aber dauerhafter Fall: zwei gleichzeitige Abrufe
desselben Covers schrieben in dieselbe Zwischendatei und konnten ein
zerschnittenes Bild hinterlassen, das nie wieder verschwindet.
- 🧹 **Selbst gefunden:** Das Häkchen „schon geladen" fragte pro Zeile die
Platte ab — und die Zeile zeichnet sich bei jedem Positionswechsel neu.
Das wäre eine Dateiprüfung pro Sekunde und Titel gewesen. Jetzt einmal je
Bildschirm aus der Datenbank.
-**Getestet:** 457 Tests grün (vorher 433), `flutter analyze` ohne Befund,
Release-APK gebaut. Neu: 16 Tests für den LRC-Parser, 5 für den
Cover-Schlüssel und 3 für die Album-Daten.
**G3 — Server-Titel nicht länger zweiter Klasse.**
- 🤍 Das Herz ist bei Server-Titeln nicht mehr ausgegraut, sondern
favorisiert **am Server** (`star`/`unstar`) — genau dort, wo der
Favoriten-Abgleich in den Einstellungen sie auch wieder abholt. Ein Titel
der Bibliothek wird weiterhin lokal favorisiert.
- ️ Weiterhin offen: Wiedergabezähler, Historie und Kategorien gibt es für
reine Server-Titel nicht — dafür bräuchten sie eine Zeile in der
Bibliothek, und das wäre ein eigener Umbau.
### 📥 Alben und Künstler offline mitnehmen — und der Zwischenspeicher hat endlich eine Grenze (2026-08-23)
Bisher landete Musik nur zufällig auf dem Gerät: was man abspielte, blieb
nebenbei liegen. Gezielt „dieses Album für die Zugfahrt" gab es nicht. Und
dieser Zwischenspeicher wuchs **unbegrenzt** — es gab nur „alles löschen".
- 📥 **Download-Pfeil neben jedem Album und Künstler** im Bereich
Download → Server. Während es läuft, steht oben ein Fortschritt mit
Abbruch-Knopf.
- ⚠️ **Rückfrage bei großen Mengen.** Ein Album geht ohne Nachfrage durch;
ab 30 Titeln fragt die App erst („347 Titel, geschätzt etwa 2100 MB").
Ein ganzer Künstler soll niemandem aus Versehen das Datenvolumen kosten.
- 🗂️ **Zwei getrennte Töpfe.** Bewusste Downloads liegen **nicht** im
Cache-Ordner. Der Grund ist keine Geschmacksfrage: den Cache-Ordner darf
Android bei Speichernot jederzeit selbst leeren — genau das darf einem
Album, das man mitgenommen hat, nicht passieren. Downloads liegen deshalb
neben der Datenbank, wo das System nichts anrührt.
- 🚧 **Obergrenze für den Zwischenspeicher**, voreingestellt 2 GB, in den
Einstellungen wählbar (aus / 512 MB / 1 / 2 / 4 / 8 GB). Ist sie erreicht,
fliegt das am längsten nicht Gehörte zuerst raus. **Heruntergeladene Titel
sind davon nie betroffen** — wer ein Album mitnimmt, hat das entschieden,
und die App nimmt es ihm nicht wieder weg.
- 📋 **Liste „Heruntergeladen"** in den Einstellungen: Titel, Künstler,
Größe, einzeln oder alle entfernbar. Sie kommt aus der Datenbank und nicht
aus dem Ordner — die Dateien heißen nach einem Hash und stünden dort sonst
als „a3f9…mp3".
- ▶️ **Die Wiedergabe schaut erst in die Downloads, dann in den
Zwischenspeicher.** Ein mitgenommener Titel hängt nie am Netz, auch wenn
zufällig eine Cache-Kopie existiert, die morgen verdrängt sein kann.
- 🧹 **Nebenbei aufgeräumt** (die Dateien waren ohnehin offen): die
Emoji-Knöpfe „📀 Alben" und „🎤 Künstler" heißen jetzt schlicht „Alben"
und „Künstler", das grelle `Colors.redAccent` ist überall dem Melo-Rot
gewichen, und die Einstellungen heißen nicht mehr „Settings".
- 🔍 **Aus dem Code-Review nachgebessert — zehn Punkte, vier davon ernst:**
**(1)** „Aus" bewirkte das Gegenteil. Bei 0 MB übersprang die App das
Aufräumen ganz, legte aber weiter jeden Titel ab — genau der unbegrenzt
wachsende Speicher, den diese Änderung beheben soll. „Aus" heißt jetzt
wirklich: nichts behalten.
**(2)** Wer sich erst **in** der Sitzung am Musikserver anmeldete, konnte
nichts herunterladen. Der Dienst hielt eine eigene Verbindung, die nur
beim App-Start eingerichtet wurde; die Titelliste kam, der Download lief
ins Leere. Erst ein Neustart half.
**(3)** Die Aufräum-Aktion konnte Dateien löschen, die die **laufende**
Warteschlange schon als Quelle eingetragen hatte — die Wiedergabe wäre
mitten im Album abgebrochen, ohne auf Streamen zurückzufallen. Benutzte
Dateien sind jetzt geschützt; im Zweifel bleibt die App lieber kurz über
der Grenze, als die Musik abzuwürgen.
**(4)** Ein bewusst heruntergeladener Titel wurde beim Abspielen **noch
einmal** in den Zwischenspeicher geladen — doppelter Platz, doppeltes
Datenvolumen.
Dazu sechs kleinere: ein einzelner Fehlschlag verdeckte nicht mehr den
Erfolg der übrigen Titel („18 heruntergeladen · 2 fehlgeschlagen"); eine
Datei ohne Buchführungs-Zeile (App mittendrin beendet) wird jetzt
nachgetragen statt für immer unsichtbar zu bleiben; zweimal antippen meldet
nicht mehr fälschlich Erfolg; „Lädt 21 von 20" gibt es nicht mehr; eine
gesenkte Grenze wirkt sofort statt erst beim nächsten gestreamten Titel;
und drei ungenutzte Methoden sind raus.
- 🕐 **Auch aus dem Review:** Der „zuletzt gehört"-Stempel wurde beim
**Aufbau** der Warteschlange gesetzt — damit hatten nach einem Albumstart
fünfzig Dateien fast denselben Zeitstempel und die Verdrängung wusste nicht
mehr, was wirklich gehört wurde. Gestempelt wird jetzt, wenn ein Titel
tatsächlich losspielt.
-**Getestet:** 433 Tests grün (vorher 411), `flutter analyze` ohne
Befund, Release-APK gebaut. Neu: 13 Tests für die Verdrängungs-Regel
(welche Datei weicht wann, bei gleichem Zeitstempel nie zufällig, und
benutzte Dateien nie) und 9 für Rückfrage-Schwelle, Größenschätzung und
Dateinamen.
### ▶️ Man sieht jetzt, welcher Titel gerade läuft (2026-08-23)
Bisher scrollte man durch die Bibliothek und musste raten, wo man gerade
ist — die einzige Rückmeldung war die Leiste ganz unten.
- 🎚️ **Drei tanzende Balken über dem Coverbild** des laufenden Titels, sein
Name in Rot. Die Balken liegen **auf** dem Cover statt daneben: eine vierte
Spalte hätte dem Titel Platz genommen, und die Zeile wäre bei jedem
Titelwechsel in der Breite gesprungen.
- ⏸️ **Pausiert bleibt die Markierung stehen, die Balken stehen still.** Man
sieht weiter, wo man ist, und zugleich, dass gerade nichts vorangeht.
- 🌐 **Überall zugleich.** Die Markierung sitzt in der Songzeile selbst, also
gilt sie in „Meine Musik“, Favoriten, Suche, hinter einem Künstler, hinter
einer Kategorie, in einer Wiedergabeliste und bei „Zuletzt hinzugefügt“ —
ohne dass irgendeine dieser Ansichten angefasst werden musste. Die
Warteschlange bekommt dasselbe Zeichen, damit man dieselbe Stelle
wiedererkennt.
-**Wer Systemanimationen abgeschaltet hat, bekommt stehende Balken.** Eine
Endlosschleife ist genau das, was diese Einstellung vermeiden soll.
- 🐛 **Beim Testen gefunden:** Die Balken lasen die Animations-Einstellung im
falschen Moment (`initState`), was Flutter mit einem Fehler quittiert. Der
Test, der prüft, dass nichts endlos läuft, hat es aufgedeckt.
-**Und beim Nachsehen noch etwas:** Zuerst hing *jede* sichtbare Zeile am
Wiedergabe-Zustand — der meldet aber auch den Pufferstand und feuert
mehrmals pro Sekunde. Die halbe Liste hätte sich also ständig neu
aufgebaut. Jetzt hört nur die eine laufende Zeile darauf; alle anderen
bekommen nur mit, wenn der Titel wechselt.
- 🔍 **Aus dem Code-Review nachgebessert:**
**(1)** Die Markierung erschien einen Frame zu spät. Scrollte man die
laufende Zeile aus dem Blick und zurück, ploppten Balken und rote Schrift
sichtbar nach — der Strom liefert seinen Wert erst im nächsten Frame. Jetzt
mit Startwert.
**(2)** Beim Wechsel auf „markiert" änderte die Zeile die Form ihres
Aufbaus, und Flutter warf sie deshalb weg und baute sie neu. Folge: das
Herz daneben verlor sein Abo und sah einen Moment lang leer aus — genau
bei dem Titel, den man gerade gestartet hat. Der Aufbau bleibt jetzt
gleich, nur der Strom wird an- und abgehängt.
**(3)** In der **Wiedergabeliste** fehlte die Markierung ganz. Der
Changelog-Eintrag oben behauptete zuerst das Gegenteil — diese Ansicht
baut ihre Zeilen selbst (rechts steht „Aus Playlist entfernen" statt Herz
und Menü) und lief deshalb nicht über die gemeinsame Songzeile. Sie hat
die Markierung jetzt wirklich.
Dazu eine Kleinigkeit: der Abdunkel-Schleier hatte seinen Eckenradius fest
verdrahtet und hätte bei einem anderen Cover-Radius nicht mehr gepasst.
- 🧪 **Testlücke geschlossen, die der Review benannt hat.** Geprüft waren nur
die Regel und die Balken für sich — die Verdrahtung dazwischen nicht. Genau
dort saßen alle drei Fehler oben. Es gibt jetzt fünf Tests dafür, unter
anderem einen, der nachweist, dass die Zeile beim Wechsel **dasselbe**
Element bleibt.
-**Getestet:** 411 Tests grün (vorher 396), `flutter analyze` ohne Befund,
Release-APK gebaut. Neu: 5 Tests für die Regel, wann eine Zeile markiert
wird (inklusive: ein Server-Titel markiert nie eine Bibliothekszeile),
5 Widget-Tests für die Balken und 5 für die Verdrahtung.
### 🔎 Die Suche trennt jetzt nach Titel, Künstler, Kategorien und Listen (2026-08-23)
Der Suchen-Tab war bisher **59 Zeilen**: ein Textfeld über einer flachen
Liederliste. Wer „Nightcore“ eingab, bekam alle Titel dieses Künstlers
untereinander — den Künstler selbst, seine Kategorie oder eine gleichnamige
Wiedergabeliste fand er nicht.
- 🗂️ **Vier Abschnitte statt einer Liste.** Titel · Künstler · Kategorien ·
Wiedergabelisten, jeder mit Überschrift und Trefferzahl. Alben stecken in
„Kategorien“ — der Album-Name *ist* in Melo die erste Kategorie eines
Titels, ein eigener Alben-Abschnitt wäre dieselbe Liste zweimal.
- ⬆️ **Treffer am Wortanfang stehen oben.** Wer „roses“ tippt, meint eher
„Roses“ als „Wild Roses“. Innerhalb dieser beiden Ränge alphabetisch.
- ⏱️ **Entprellt (250 ms).** Vorher baute jeder einzelne Tastendruck die
komplette Trefferliste neu auf.
- 🕘 **Letzte acht Suchen.** Sie stehen da, solange das Feld leer ist; Antippen
übernimmt den Begriff. Gemerkt wird ein Begriff erst beim Abschicken —
halbe Wörter aus dem Tippen sind keine Suche, die man wiederholen möchte.
- 🧹 **Der Tab sieht aus wie die anderen.** Er war ein `Scaffold` mit eigener
`AppBar` *innerhalb* eines Tabs, während „Meine Musik“ und „Favoriten“ eine
Kopfzeile im Inhalt haben. Jetzt einheitlich, mit demselben runden Suchfeld
wie auf der Startseite.
- ♻️ **Aufgeräumt, weil es sonst eine dritte Kopie gegeben hätte:** Die
Ansicht „Überschrift + Liederliste“ stand zweimal wortgleich im Baum
(hinter einem Künstler und hinter einer Kategorie). Sie ist jetzt eine
Klasse, die auch die Suche benutzt. Ebenso ist die einzelne Songzeile aus
der Liederliste herausgelöst — die Suche setzt einzelne Zeilen in ihre
Abschnitte, eine ganze Liste wäre dort eine verschachtelte Scrollfläche.
- 🐛 **Beim Bauen selbst gefunden und behoben:** Der Verlauf wurde zuerst
beim Aufbau der Trefferliste geschrieben. Das hätte eine Endlosschleife
ergeben — schreiben löst ein Neuzeichnen aus, das wieder schreibt.
- 📴 **Kein Server-Teil.** Bewusst so entschieden: die Suche läuft rein
lokal, damit sie sofort antwortet und ohne Netz funktioniert.
- 🔍 **Aus dem Code-Review nachgebessert (die wichtigsten drei):**
**(1)** Die Suche fasste „Neoni", „NEONI" und „Neoni " zu einem Treffer
zusammen — die Titel dazu wurden aber unter der rohen Schreibweise gesucht.
Ergebnis: „0 Titel" und ein leerer Bildschirm beim Antippen. Beide Seiten
benutzen jetzt dieselbe Normalform, und verschiedene Schreibweisen
desselben Künstlers fallen dabei zusammen statt sich gegenseitig zu
verstecken. Dasselbe galt für Kategorien.
**(2)** Eine Kategorie, an der nur noch gelöschte Titel hängen, erschien
als Treffer und führte ins Leere. Solche Treffer fallen jetzt weg.
**(3)** Alle Abschnitte sind auf fünf Einträge gedeckelt, nicht nur die
Titel. Eine Eingabe wie „a" hätte sonst hunderte Künstlerzeilen bei jedem
Neuzeichnen gebaut. Die Gesamtzahl steht ohnehin in der Überschrift.
Dazu kleinere Sachen: die beiden Datenbank-Ströme werden nicht mehr bei
jedem Neuzeichnen neu abonniert, der Fehlerfall der Wiedergabelisten wird
behandelt, und das Löschkreuz im Suchfeld erscheint sofort statt nach
250 ms — vorher sprang das Textfeld dabei um eine Knopfbreite enger.
- 🗑️ **Verlauf lässt sich löschen.** Ein „Löschen" neben der Überschrift.
(Ohne das hätte es keine Möglichkeit gegeben, gespeicherte Suchbegriffe
wieder loszuwerden.)
- ♻️ **Tot geworden und entfernt:** `MeloDb.searchSongs` samt Test — die
alte LIKE-Abfrage hat nach dem Umbau keinen Aufrufer mehr.
-**Getestet:** 396 Tests grün (vorher 360), `flutter analyze` ohne Befund,
Release-APK gebaut. Neu: 25 Tests für die Suchregeln und die Bündelung
nach Schreibweise, 7 für den Verlauf, 7 Widget-Tests für die Abschnitte
(inklusive einem, der prüft, dass wirklich entprellt wird).
### 🔁 Positions-Timer überlebt jetzt das Wegwischen (2026-08-23)
- ⏱️ **Behoben: nach dem Wegwischen der Benachrichtigung merkte sich die App
keine Hörposition mehr.** `stop()` brach dabei den Timer ab, der alle fünf
Sekunden die Wiedergabeposition sichert — und der entsteht nur **einmal**
beim App-Start. Einmal abgebrochen, kam er nie zurück: „Fortsetzen an alter
Stelle" hörte still auf zu funktionieren, und zum Server wurde nicht mehr
gemeldet, was gehört wurde. Erst ein Neustart half. Der Timer läuft jetzt
durch; er kostet nichts, weil er von selbst aussteigt, solange nichts
spielt. Abgebrochen wird er nur noch beim endgültigen Aufräumen.
- 📄 **Korrektur am eigenen Review-Bericht (K4).** Dort stand, die
Alben-Ansicht sei „verlorengegangen". Das war falsch: der Alben-Reiter wurde
**bewusst** durch „Kategorie" ersetzt — der Album-Name eines Liedes *ist* in
Melo seine erste Kategorie (`category_list.dart:12`, `kategorienAusTags`).
Was wirklich fehlt, ist die Album-**Detailansicht** (großes Cover,
Künstler, Gesamtdauer) und die Sortierung nach Tracknummer — letztere
braucht zuerst eine Schema-Erweiterung, weil die Tracknummer bisher
überhaupt nicht eingelesen wird. Im Bericht ist der Punkt richtiggestellt.
-**Getestet:** 360 Tests grün, `flutter analyze` ohne Befund.
### 🚑 Fünf P0-Fehler behoben: Benachrichtigung, Offline, Cover, Warteschlange (2026-08-23)
Ergebnis einer vollständigen Durchsicht der App gegen den Maßstab „Apple
Music". Fünf Punkte waren keine fehlenden Funktionen, sondern **Versprechen,
die die App gab und der Code nicht hielt**.
- 🔔 **Die Wiedergabe-Benachrichtigung erschien auf Android 13+ gar nicht.**
Dafür braucht das System die Berechtigung `POST_NOTIFICATIONS` — sie fehlte
im Manifest, auch im fertig gebauten APK. Die Musik lief, aber Notification
und die Bedienknöpfe darin blieben unsichtbar; steuern ließ sich nur in der
geöffneten App. Die Berechtigung ist jetzt drin und wird **erst dann**
erfragt, wenn wirklich ein Titel läuft — beim kalten Start käme die Frage
ohne Zusammenhang, und zweimal weggetippt sperrt Android sie dauerhaft.
- 📴 **Der Offline-Schalter tat nichts.** Er speicherte brav ein Ja/Nein, aber
**niemand las es**: die App streamte weiter und verbrauchte Mobilfunkdaten,
obwohl „Offline-Modus" anstand. Jetzt greift er an allen drei Wegen in die
Warteschlange — Abspielen, „Als Nächstes", „Zur Warteschlange". Titel vom
Gerät laufen immer, Titel vom Server nur, wenn sie schon hier liegen. Was
übersprungen wird, sagt die App: *„Offline-Modus: 3 Titel übersprungen —
nicht heruntergeladen"*, statt still eine kürzere Liste zu spielen.
- 🖼️ **Titel vom Server hatten nie ein Coverbild.** Die App konnte Cover nur
aus Dateien laden; das Bild vom Navidrome-Server liegt aber hinter einer
Web-Adresse. Jeder Server-Titel war deshalb ein graues Notensymbol — in der
Liste, im Player, auf dem Sperrbildschirm. Jetzt lädt die App auch
Web-Cover, und der Server-Browser gibt die Adresse überhaupt erst mit
(vorher wurde sie an beiden Stellen schlicht vergessen).
- ⏭️ **Warteschlange umsortieren riss die Wiedergabe auf.** Jedes Entfernen
und jedes Verschieben baute die *ganze* Warteschlange neu auf: hörbarer
Aussetzer, Sprung an den Titelanfang — und der laufende Titel wurde dabei
**jedes Mal erneut als Wiedergabe gezählt**. Die Sortierung „Wie oft
abgespielt" zählte damit Queue-Bedienung statt Hören. Jetzt wird nur der
eine betroffene Eintrag bewegt; die Wiedergabe merkt davon nichts.
- 🤍 **Das Herz bei Server-Titeln log.** Ein Titel, der nur am Server liegt,
hat keinen Eintrag in der Bibliothek — das Herz bekam deshalb eine leere
Kennung, füllte sich beim Antippen und legte einen Favoriten an, der in der
Favoritenliste nie auftauchte. Es ist jetzt sichtbar ausgegraut, mit dem
Hinweis „Nur für Titel in deiner Bibliothek".
-**Nebenwirkung der Offline-Umstellung, aber eine gute:** „Als Nächstes"
und „Zur Warteschlange" sahen bisher gar nicht im Zwischenspeicher nach und
streamten einen Server-Titel auch dann, wenn er längst auf dem Gerät lag.
Alle drei Wege in die Warteschlange bauen die Abspielquelle jetzt an
derselben Stelle — ein bereits geladener Titel läuft immer von der Platte.
- 🧹 **Nebenbei:** „📴 Offline-Modus" heißt jetzt ohne Emoji „Offline-Modus"
(Emoji sehen auf jedem Hersteller-Handy anders aus und lassen sich nicht
einfärben), und der Untertitel sagt, was der Schalter wirklich tut.
- 🔍 **Aus dem Code-Review nachgebessert (drei Punkte):**
**(1)** Die Offline-Meldung hing an der falschen Stelle. Sie stand in der
Bibliotheksliste — die zeigt aber nur Titel vom Gerät, dort wird nie etwas
übersprungen. Beim Server-Album, wo es wirklich passiert, wurde sie
verworfen. Jetzt sitzt sie dort, wo sie greifen kann; und „nichts davon ist
heruntergeladen" erscheint nicht mehr als *„Fehler: …"* — das ist kein
Fehler, sondern die eingestellte Wahl.
**(2)** Beim Umsortieren wurde die Warteschlange erst *nach* dem Player
nachgeführt. In der Lücke dazwischen konnte der Player schon einen neuen
Titelindex melden, während die App noch die alte Liste kannte — falscher
Titel im Player, Wiedergabe für das falsche Lied gezählt. Buchführung steht
jetzt vor dem Player, genau wie beim normalen Abspielen.
**(3)** Die Abfrage der Benachrichtigungs-Berechtigung hatte keinen
Fehlerpfad. Läuft beim Start noch die Abfrage der Musik-Berechtigung, wirft
Android — das wäre als unbehandelter Fehler durchgeschlagen. Jetzt wird es
nur protokolliert; die Wiedergabe läuft weiter, nur eben ohne
Benachrichtigung.
-**Getestet:** 360 Tests grün (vorher 328), `flutter analyze` ohne Befund,
Release-APK gebaut. Im fertigen APK ist `POST_NOTIFICATIONS` nachgeprüft
enthalten. Neu: 39 Tests für Offline-Filter, Warteschlangen-Mutation,
Zähler-Nachführung, Server-Cover und das ausgegraute Herz. Entfallen: 9
Tests für `newPlayingIndex` — die Funktion gab es nur, um nach dem
Neuaufbau der Warteschlange den laufenden Titel wiederzufinden, und den
Neuaufbau gibt es nicht mehr.
- 📋 **Nicht in dieser Runde:** Alben-Ansicht, Suche über den Server,
gezieltes Herunterladen und die Cache-Obergrenze. Die stehen als P1 im
Review-Bericht unter `.ui-design/reviews/melo_vollwertigkeit_20260823.md`.
Dazu zwei Punkte, die erst bei dieser Arbeit auffielen und dort als **G9**
und **G10** stehen: `stop()` bricht den Positions-Timer dauerhaft ab (nach
dem Wegwischen der Benachrichtigung merkt sich die App keine Hörposition
mehr — vorbestehend), und die Server-Cover haben nur einen Zwischenspeicher
im Arbeitsspeicher, keinen auf der Platte.
### 🏷️ Der zweite Tab heißt wieder „Download" (2026-08-21)
- 📥 **Aus „Online" wird „Download".** Der Bereich behält seinen Inhalt
unverändert — YouTube-Downloader und Server-Bibliothek als Unterreiter.
Nur die Beschriftung im Hauptmenü ändert sich.
Die vier Bereiche heißen jetzt: **Meine Musik · Download · Suchen ·
Favoriten**.
- ⬇️ **Passendes Symbol dazu.** Statt der Note steht wieder ein Download-Pfeil
im Menü — genau das Symbol, das der Tab schon einmal hatte, bevor er in
„Online" umbenannt wurde. Also nichts Neues erfunden, sondern der frühere
Stand wiederhergestellt.
- 📄 **Auch die Überschrift im Bereich selbst zieht nach.** Sie stand noch auf
„Online" — ein Tab namens „Download", der auf einen Bildschirm namens
„Online" führt, wäre verwirrend gewesen.
-**Getestet:** 328 Tests grün, `flutter analyze` ohne Befund.
### 🧭 Hauptmenü wieder deutlich sichtbar (2026-08-21)
**Zuerst die Entwarnung:** Es fehlt nichts und nichts ist verschoben. Alle
vier Bereiche standen und stehen unten im Hauptmenü — `lib/main.dart` wurde
von der UI-Politur überhaupt nicht angefasst.
- 🐛 **Behoben: die Politur hatte das Menü abgeschwächt.** Zwei Fehler von
mir aus der letzten Runde:
- Ich hatte für **aktiv und inaktiv dieselbe Schriftgröße** (12) gesetzt.
Damit verlor der aktive Bereich den Größenunterschied (sonst 14 zu 12),
an dem man auf einen Blick erkennt, wo man ist.
- Die inaktiven Einträge liefen auf der **blassen Beiwerk-Stufe**. Ein
Hauptmenü ist kein Beiwerk — sie sind jetzt wieder kräftig.
-**Zusätzlich deutlicher gemacht:**
- Eine **Haarlinie über dem Menü** — auf schwarzem Grund ging die Leiste
sonst optisch im Inhalt auf.
- Der aktive Bereich hat jetzt ein **gefülltes Symbol** (ausgefülltes Herz,
volle Note). Wer Rot schlecht sieht, erkennt trotzdem, wo er steht —
vorher unterschied nur die Farbe.
- Der aktive Eintrag ist fetter gesetzt.
- 🔒 **Damit das keine Politur mehr abschwächen kann:** ein neuer Test prüft,
dass alle vier Bereiche vorhanden **und auf dem Bildschirm** sind, dass das
Menü am unteren Rand klebt, dass jeder Tipp dorthin wechselt, und dass der
aktive Bereich sich nicht nur durch Farbe abhebt.
- 🔧 **Details für Technikinteressierte:**
- Neu: `test/hauptmenue_test.dart` (10 Prüfungen).
- `bottomNavigationBarTheme`: keine feste `fontSize` mehr (damit Flutters
14/12 gelten), `unselectedItemColor` von `text3` auf `text2`,
`showUnselectedLabels` ausdrücklich gesetzt.
- Beim Schreiben des Tests ist eine **Test-Falle** aufgefallen und gelöst:
`await db.close()` nach einem `tester.tap` lässt den Testlauf
**unbegrenzt hängen**. Rezept dagegen steht im Test und in `BACKLOG.md`.
Beim schon bekannten Hänger in `song_detail_sheet_test.dart` hilft es
allerdings nicht — dort ist die Ursache eine andere, jetzt ebenfalls im
Backlog vermerkt.
-**Getestet:** 328 Tests grün (10 neue), `flutter analyze` ohne Befund.
### ✨ UI-Politur: Kontrast, Abstände, Typografie, Bewegung (2026-08-21)
Kein neues Design — dasselbe Melo in sauber. Schwarz/Rot, die vier Tabs und
+17
View File
@@ -9,6 +9,10 @@
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- Equalizer/Bass/Virtualizer (android.media.audiofx) -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<!-- Wiedergabe-Benachrichtigung: ab Android 13 (API 33) verlangt das System
dafuer eine Laufzeit-Berechtigung. Ohne sie laeuft der Vordergrunddienst
zwar, aber Notification und Bedienknoepfe darin bleiben unsichtbar. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Musikerkennung: kurze Aufnahme ueber das Mikrofon fuer ACRCloud. -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<!-- Musik lesen: Android 13+ (API 33) nutzt READ_MEDIA_AUDIO, ältere READ_EXTERNAL_STORAGE. -->
@@ -44,12 +48,25 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- "Teilen -> Melo" aus der YouTube-App. Vorher musste man die
Adresse von Hand kopieren und einfuegen. -->
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/plain"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Android Auto: ohne diesen Eintrag taucht Melo dort nicht auf,
auch wenn der MediaBrowserService unten angemeldet ist. Die
Inhalte liefert MeloAudioHandler.getChildren. -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
@@ -6,6 +6,7 @@ import android.media.audiofx.AudioEffect
import android.media.audiofx.BassBoost
import android.media.audiofx.Equalizer
import android.media.audiofx.Virtualizer
import android.provider.Settings
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
@@ -116,14 +117,36 @@ class AudioEffectsBridge(private val activity: Activity) {
/**
* Oeffnet die Klangeffekt-Oberflaeche des Systems — auf POCO/Xiaomi ist
* das die Dolby-Atmos-Seite. Gibt false zurueck, wenn kein System die
* Anzeige unterstuetzt.
* das die Dolby-Atmos-Seite unter Einstellungen -> Ton. Gibt false
* zurueck, wenn kein System eine passende Anzeige unterstuetzt.
*
* MIUI/HyperOS registriert KEINE Activity fuer den Android-Standard-Intent
* [AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL] — resolveActivity
* liefert dort immer null, obwohl Dolby Atmos vorhanden ist ("kein
* System-Klangeffekte" trotz vorhandenem Dolby Atmos auf dem POCO X7 Pro).
* Der Xiaomi-Weg geht deshalb direkt ueber die System-Ton-Einstellungen,
* in denen Dolby Atmos als Eintrag liegt.
*/
private fun openSystemPanel(): Boolean {
val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
val xiaomiTonEinstellungen = Intent()
.setClassName("com.android.settings", "com.android.settings.Settings\$SoundSettingsActivity")
if (tryStartActivity(xiaomiTonEinstellungen)) return true
// Universeller Fallback: bringt auf jedem Android-Geraet die
// System-eigene Ton-Seite — auf MIUI/HyperOS ebenfalls die Seite mit
// dem Dolby-Atmos-Eintrag, falls obige Xiaomi-Activity mal umbenannt wird.
if (tryStartActivity(Intent(Settings.ACTION_SOUND_SETTINGS))) return true
// Letzter Versuch: der Android-Standard, funktioniert auf Geraeten,
// die tatsaechlich eine Effekt-Anzeige dafuer registrieren.
val standardIntent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, sessionId)
.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, activity.packageName)
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
return tryStartActivity(standardIntent)
}
private fun tryStartActivity(intent: Intent): Boolean {
if (intent.resolveActivity(activity.packageManager) == null) return false
activity.startActivity(intent)
return true
@@ -0,0 +1,47 @@
package de.baka.melo
import android.content.Intent
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* Reicht Text weiter, den eine andere App ueber "Teilen" an Melo schickt —
* in der Regel eine YouTube-Adresse.
*
* Eigene Bruecke statt eines Zusatzpakets: das Projekt hat fuer MediaStore
* und Klangeffekte bereits eigene (siehe MediaStoreBridge).
*/
class GeteiltBridge {
companion object {
const val CHANNEL = "de.baka.melo/geteilt"
}
private var offen: String? = null
/**
* Merkt sich den Text eines SEND-Intents. Wird sowohl beim Start als
* auch bei onNewIntent aufgerufen: die Activity laeuft mit singleTop,
* ein zweites Teilen startet sie also nicht neu.
*/
fun nimm(intent: Intent?) {
if (intent?.action != Intent.ACTION_SEND) return
if (intent.type != "text/plain") return
offen = intent.getStringExtra(Intent.EXTRA_TEXT)
// Aus dem Intent entfernen, damit ein Lagewechsel (Drehen) ihn nicht
// ein zweites Mal liefert.
intent.removeExtra(Intent.EXTRA_TEXT)
}
fun handle(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"hole" -> {
// Nach dem Abholen leeren: sonst spraenge die App bei jedem
// Wiedereintritt erneut in den Downloader.
val text = offen
offen = null
result.success(text)
}
else -> result.notImplemented()
}
}
}
@@ -1,11 +1,13 @@
package de.baka.melo
import android.content.Intent
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : AudioServiceActivity() {
private var audioEffects: AudioEffectsBridge? = null
private val geteilt = GeteiltBridge()
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -21,6 +23,24 @@ class MainActivity : AudioServiceActivity() {
flutterEngine.dartExecutor.binaryMessenger,
MediaStoreBridge.CHANNEL
).setMethodCallHandler(mediaStore::handle)
// Den Intent, mit dem die App gestartet wurde, gleich mitnehmen —
// Dart fragt kurz danach danach.
geteilt.nimm(intent)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
GeteiltBridge.CHANNEL
).setMethodCallHandler(geteilt::handle)
}
/**
* Die Activity laeuft mit singleTop: ein zweites Teilen startet sie
* nicht neu, sondern liefert hier einen neuen Intent.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
geteilt.nimm(intent)
}
override fun onDestroy() {
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Meldet Melo bei Android Auto als Musik-App an. Ohne diese Datei taucht die
App dort gar nicht erst auf — der MediaBrowserService allein reicht nicht.
-->
<automotiveApp>
<uses name="media"/>
</automotiveApp>
@@ -0,0 +1,90 @@
# Context Brief — Review der Sync-Ausbau-Spec
**Review-Gegenstand:** `/home/dustin/mello-dev/app/docs/superpowers/specs/2026-08-27-sync-ausbau-design.md`
**Review-Modus:** Exhaustive (reines Design-Dokument). ABER: Die Spec macht
viele FAKTISCHE Behauptungen über existierenden Code — solche Behauptungen
bitte präzise gegen den echten Code prüfen (grep/read, read-only).
**Codebase-Zustand:** Branch `fix/p0-vollwertigkeit` in
`/home/dustin/mello-dev/app` (kein Worktree-Sonderfall, Branch aktuell mit
origin). Server-Code: `/home/dustin/scripts/melo_cloud.py` (read-only
ansehen erlaubt, NIE ändern).
## Projekt
Melo: Flutter-Musik-App (Android), schwarz/rot, 5 Tabs. Backend:
Navidrome (Subsonic, read-only) + Melo-Cloud (`melo_cloud.py`,
cloud.baka-net.de, Bearer-JWT via BakaAuth) + yt-Proxy. 602 Tests grün.
Nutzer: Dustin (Admin), Baka, Tinker — kleine private Nutzerbasis,
~330 Songs auf dem Server. Projekt-Prinzip „Ponytail“: minimale, robuste
Lösungen; YAGNI wird ernst genommen.
## Bereits vom Auftraggeber ENTSCHIEDEN (im Brainstorming, mit Begründung)
Reviewer dürfen diese Entscheidungen kritisieren, sollen aber wissen, dass
sie bewusst und informiert getroffen wurden:
1. Cross-Platform (Mac/Win) = eigenes späteres Projekt, NICHT diese Spec.
2. Upload-UX: Auswahl-Modus-Erweiterung; Auto-Upload bleibt zusätzlich.
3. Playlist-Konflikte: Union-Merge (kein LWW, kein read-only).
4. Kein Delta-Protokoll, kein Hintergrund-Sync, kein Chunked-Upload (YAGNI).
5. Favoriten-Merge-Fix ist gesetzt (Datenverlust-Bug).
6. Umfang: Einzel-Song-Offline + Playlist-Sync + Notification/Bericht + SSE.
## Verifizierte Schlüsselfakten aus der Bestandsaufnahme (2026-08-27)
App-Sync (lib/services/sync_service.dart, melo_cloud_service.dart):
- Upload existiert: Multipart POST /upload, 50 MB Limit, 120s Timeout;
Server-Dedup sha256 + fpcalc-Fingerprint (Jaccard>0.7).
- planeSync() lädt ALLE Songs ohne cloudId hoch; Tombstones beidseitig;
Lösch-Bremse nur für serverLoeschen (>10 && >1/3).
- _gleicheFavoritenAb() POSTet lokale Favoriten als KOMPLETT-ERSATZ
(der Datenverlust-Bug); MeloCloudService.favoriten() (GET) existiert,
wird nie aufgerufen.
- Auto-Sync bei Start/Resume, 15-Min-Drossel (sollAutoSync); Fortschritt
als ChangeNotifier-Felder, UI nur in settings_screen.dart.
- Kein SSE in der App, kein Playlist-Sync in der App.
Server (melo_cloud.py):
- Endpunkte: list/upload/download/stream(Range)/delete/update/rename/
playlists(CRUD+songs+positions)/favorites(GET/POST/toggle mit set)/
history/sync(since)/subscribe(SSE, Heartbeat 30s).
- SSE-Events: song_delete, song_update, song_favorite werden emittiert;
UPLOAD feuert KEIN Event; playlist_update dokumentiert aber nie emittiert.
- Playlists/Favoriten: harte DELETEs, KEINE Tombstones.
- handle_favorites_sync = Full-Replace. handle_list liefert immer ALLES
inkl. Grabsteine (kein ETag/Since).
- Uploads landen in /var/lib/melo-cloud/registry/ und werden AKTIV nach
/home/dustin/navidrome/music gehardlinkt + Navidrome-Scan getriggert.
- Achtung bekanntes Server-Risiko: handle_list selektiert Spalte
ist_korrupt, die im CREATE-TABLE-Schema fehlt (nur Live-DB hat sie).
- Uploads/Downloads liegen serverseitig komplett im RAM.
Offline (download_service.dart u. a.):
- Alben/Künstler-Download in App-Speicher (Application-Support/
melo_downloads), Drift-Tabelle Downloads (navidromeId), Fortschritt+
Abbrechen; Rückfrage ab 30 Titeln. Abspiel-Cache 2 GB LRU getrennt davon.
Melo v2 (Referenz, /home/dustin/mello-dev/referenz/melo-app-code.md):
- Hatte: bidirektionalen Favoriten-Merge (Union) + deterministischen
set-Push beim Toggle; persistente Sync-Notification (i/n); Sync-Bericht
nach >24h; Konflikt-Dialog. Bekannte v2-Lektionen: Sync-Zeitstempel =
Snapshot VOR listSongs (Tombstone-Race MED-1); neue Songs sofort MIT
cloudId in DB (Doppel-Download MED-2); Dateinamen-Kollisionszähler.
Plattform:
- flutter_local_notifications ist NICHT in pubspec.yaml (wäre neu).
- http-Paket vorhanden (client.send für SSE-Streaming möglich).
- POST_NOTIFICATIONS wird seit kurzem beim App-Start angefragt.
- Drift-DB mit Migrationen vorhanden (database.dart, schemaVersion).
## Regeln für Reviewer
- READ-ONLY: nichts schreiben außer der eigenen State-Datei, keine
git-Operationen, keine Server-Prozesse anfassen, keine .env lesen.
- Jede Behauptung über existierenden Code mit Datei:Zeile belegen
(grep/read). Behauptungen ohne Beleg als [UNVERIFIED] taggen.
- Findings-Severity: P0 (blockiert die Spec / Datenverlust), P1 (muss vor
Implementierung geklärt werden), P2 (sollte), P3 (nice to have).
- Für jedes P0/P1: falsifizierbaren Check nennen (welcher grep/read würde
es widerlegen?), wenn möglich als verification_command (read-only).
@@ -0,0 +1,130 @@
# Review-Panel-Bericht — Sync-Ausbau-Spec
**Gegenstand:** `docs/superpowers/specs/2026-08-27-sync-ausbau-design.md`
**Datum:** 2026-08-27
**Panel:** 5 Reviewer (Feasibility, Risk, Devil's Advocate, Flutter/Android,
Sync-/Verteilte-Systeme) + Vollständigkeits-Auditor + konsolidierter
Verifizierer + Oberster Richter. Alle Modell: Opus.
**Ablauf:** Phase 3 (unabhängig) → 4 (Reflexion) → 5 (Debatte, 2 Runden) → 7
(blinde Schlussurteile) → 8 (Audit) → 10+11 (Zitat- + Severity-Prüfung) → 14
(Urteil).
**Review-Modus:** Exhaustive (Design-Dokument), aber alle Bestandsbehauptungen
gegen echten Code geprüft.
**Verdikt: SPEC NACHSCHÄRFEN DANN FREIGEBEN · Score 6/10 · Konfidenz: Hoch**
Panel-Scores: 6 / 5 / 5 / 5 / 5 (Mittel 5,2). Der Richter liegt darüber, weil
die Faktenprüfung zwei tragende Säulen der Panel-Kritik entfernt hat.
---
## Kurzfassung
Die Spec ist **im Kern richtig und faktisch überwiegend korrekt** — rund zwanzig
Behauptungen über den Bestandscode haben fünf unabhängigen Prüfungen
standgehalten, und in zwei Fällen lag das Panel falsch, nicht die Spec. Es gibt
**kein P0 im Geltungsbereich**: Der Datenverlust-Bug, den die Spec beheben will,
kann heute nichts verlieren (die Live-DB hat 0 Favoriten, und
`MeloCloudService.favoriten()` hat null Aufrufer).
Drei belegte Substanzmängel verhindern die direkte Freigabe:
1. Eine **falsche Garantie** über Lösch-Propagation.
2. **Fünf zu optimistische Bestandsbehauptungen** („existiert bereits", wo es
das nicht tut).
3. **Zu großer Umfang** für einen Wurf: sechs Ziele, von denen genau eines auf
nichts wartet und einen echten Bug behebt.
**Der wichtigste Einzelbefund richtet sich gegen das Panel, nicht gegen die
Spec:** Das Panel konvergierte in Debattenrunde 2 auf einen „Basis-Snapshot" als
Merge-Mechanismus. Die separate Verifikation hat nachgerechnet — liefert
`GET /favorites` fälschlich eine leere Menge (was `parseFavoriten` heute bei
jedem Fehler im 200er-Körper tut, und sechs Handler desselben Servers antworten
so), kollabiert die Formel und **löscht den gesamten Bestand auf allen Geräten**.
Der empfohlene Ersatz wäre gefährlicher gewesen als das Problem. Er ist
abgelehnt.
---
## Umfang und Grenzen
Geprüft wurde ein Design-Dokument, kein laufender Code. Nicht bewertbar:
Laufzeitverhalten, echte Gerätetests, Server-Last unter echten Bedingungen.
Alle Code-Aussagen sind read-only verifiziert (~70 Datei:Zeile-Belege).
Epistemik-Labels: [VERIFIZIERT] [KONSENS] [EINZELQUELLE] [UNVERIFIZIERT]
Typ-Labels: [BESTANDSFEHLER] (existiert heute) [PLAN-RISIKO] (entsteht erst
durch Umsetzung)
---
## Konsens-Punkte (vom Richter bestätigt)
- Die **Lösch-Kette ist Glied für Glied bestätigt** [VERIFIZIERT]
[BESTANDSFEHLER]: Verschwindet eine Datei lokal (SD-Karte nicht eingehängt,
Dateimanager, Verschieben), markiert der Scan sie als gelöscht, der Sync meldet
das dem Server, und der Server löscht die Audiodatei aus **beiden** Orten
(Registry + Navidrome-Ordner). Für die Datei gibt es keinen Grabstein, der
Reparaturweg ist verifiziert kaputt. Die Lösch-Bremse greift bei 325 Titeln
erst ab 109 gleichzeitigen Löschungen. **Nicht von dieser Spec verursacht**
bisher nie ausgelöst (`SUM(deleted)=0`), aber ein echter Bestandsfehler.
- Die **Merge-Semantik der Playlisten ist unterspezifiziert** [KONSENS]: vier
offene Entscheidungen (Identität, Reihenfolge bei Gleichstand,
Tombstone-Verhalten, Namens-Zuordnung als Nicht-Funktion).
- **`flutter_local_notifications` erzwingt Gradle-Umbau** (Desugaring) —
der einzige harte Build-Blocker der Planung.
## Wo das Panel falsch lag (Verifikation korrigiert)
- „Auswahl-Modus existiert so nicht" — **widerlegt**, er existiert
(`sortable_song_list.dart`).
- „Pro-Song-Lade-Loop bringt keinen Fortschritt/Doppel-Lauf-Schutz mit" —
**widerlegt**, `DownloadService.lade()` bringt beides mit. **Die Spec hatte
recht.**
- „Navidrome-Playlist-Import erzeugt Merge-Explosion" — **widerlegt**, die
importierten Playlisten sind leer (ID-Räume treffen nie).
- „SSE erschöpft den Worker-Pool" — **vom Panel selbst zurückgezogen**
(ThreadingMixIn ohne Pool).
---
## Aktionsliste (17 Punkte, priorisiert)
Vollständig mit Formulierungsvorschlägen in
`state/phase_14_judge_ruling.md`, Abschnitt 6. Die wichtigsten:
| # | Sev | Änderung |
|---|---|---|
| A1 | P1 | **`POST /favorites` (Voll-Ersatz) ersatzlos streichen** — nur noch additive `toggle set:true` für `lokal \ server`. Macht den Datenverlust-Bug *strukturell* unmöglich statt per Regel. |
| A2 | P1 | **`parseFavoriten` härten** (Fehler im 200er-Körper werfen, nicht `[]` liefern) + den grünen Bestandstest mitändern, der heute das Gegenteil festschreibt. |
| A3 | P1 | **Falsche Lösch-Garantie streichen** — auch *online* entfernte Herzen propagieren nicht zuverlässig. |
| A4 | P1 | **Basis-Snapshot ausdrücklich ablehnen** und begründen (Gegen-Empfehlung zum Panel). |
| A5 | P1 | **Playlist-Sync auf einseitige Sicherung reduzieren** — löst alle vier offenen Semantik-Fragen ersatzlos auf. Beidseitiger Merge wird eigene Spec. |
| A6 | P1 (Spec) / **P0 (Backlog)** | **Irreversiblen Löschpfad benennen** — plus die eine Frage, die kein Reviewer entscheiden kann (siehe unten). |
| A7 | P1 | **SSE herausnehmen** — der Hauptnutzen hängt am `song_upload`-Event, das die Spec selbst auslagert; acht Fehlerklassen für heute null Adressaten. |
| A8 | P1 | **Notification halbieren**: Sync-Bericht sofort, persistente Notification als eigene Stufe mit Beweis-Build. |
| A9 | P1 | **Fehlerfälle von Zusagen auf zu bauende Arbeit umstellen** — fünf „bestehende Schutzmechanismen" gibt es nicht. |
| A10 | P1 | **Testliste um Bestandsänderungen erweitern** — sie nennt heute nur neue Tests. |
| A17 | P1 | **Auslieferungsreihenfolge festschreiben** — fehlt heute ganz. |
| A11A16 | P2/P3 | Bestandsbehauptungen korrigieren, offene Entscheidungen treffen, Kleinigkeiten. |
**Bedingung des Richters:** Die nachgeschärfte Fassung wird einmal kurz
gegengelesen, bevor der Implementierungsplan entsteht — A1, A5 und A7 ändern,
*was* gebaut wird, nicht nur *wie* es beschrieben ist.
---
## Meta-Beobachtung zum Verfahren
Bemerkenswert stark: ~70 geprüfte Belege, **keine einzige Halluzination**,
keine Fehlzuordnung. Mehrere Reviewer haben eigene P0-Befunde aktiv falsifiziert
— das Gegenteil von Konsens-Drift.
Bemerkenswert schwach, und lehrreich: **Das Panel prüfte die Spec adversarial,
seine eigene Empfehlung aber nur konsensual.** Sobald Runde 2 auf den
Basis-Snapshot konvergiert war, griff ihn niemand mehr mit derselben Härte an.
Erst die separate Verifikation deckte auf, dass er selbst einen Löschpfad
schafft. Zweitens war der Suchraum zu klein: zwei Runden „Outbox oder
Basis-Snapshot" behandelten den Schreibweg als gegeben — die billigste Lösung
bestand darin, einen Aufruf zu **streichen**, nicht einen Mechanismus zu bauen.
Drittens: die Testschuld fand erst der Vollständigkeits-Auditor; fünf Reviewer
sezierten `sync_service.dart` zeilengenau und betraten `test/` nur einmal.
@@ -0,0 +1,864 @@
# Phase 14 — Urteil des Obersten Richters
Gegenstand: `docs/superpowers/specs/2026-08-27-sync-ausbau-design.md` (Design, noch
nicht implementiert).
Grundlage: `phase_10_11_verification.md`, `phase_8_audit.md`, beide
Debatten-Zusammenfassungen, die fünf Phase-7-Schlussurteile, plus eigener
Code-Blick (read-only, grep/sed/read auf `lib/`, `test/`,
`/home/dustin/scripts/melo_cloud.py`).
Maßstab: Auftraggeber ist ein Hobby-Entwickler, drei Nutzer (Dustin, Baka,
Tinker), Projekt-Prinzip „minimale, robuste Lösungen". Keine
Enterprise-Sync-Architektur. „Feature streichen oder verschieben" ist eine
zulässige und hier mehrfach die richtige Antwort.
---
## 0. Verifikation zuerst — was die Faktenprüfung entscheidet
Die konsolidierte Verifikation schlägt jede widersprechende Panel-Behauptung.
Ich habe stichprobenartig gegengeprüft und **keine ihrer Korrekturen widerlegen
können**. Es gilt daher:
**0.1 Kein P0 im Geltungsbereich dieser Spec. [VERIFIZIERT]**
Das einzige verbliebene Panel-P0 (F1, `parseFavoriten` + Superset-Invariante)
steht auf totem Code: `MeloCloudService.favoriten()`
(`lib/services/melo_cloud_service.dart:258-265`) hat **null Aufrufer** in `lib/`
und `test/` — eigener Gegen-Grep bestätigt. Die Live-DB hat **0 Favoriten**.
Der Befund kann heute nichts verlieren; er wird erst durch die Spec scharf. Nach
der Regel des Context Briefs (P0 = Bestandsfehler mit Datenverlust *oder* durch
die Spec neu geschaffener Datenverlust) ist das **P1**. Die Panel-Aussage „ein P0
hält" ist nicht haltbar.
**0.2 Die Lösch-Kette ist Glied für Glied bestätigt. [VERIFIZIERT]**
`markMissing` (`lib/library/database.dart:237-245`) → `planeSync serverLoeschen`
(`lib/services/sync_service.dart:68-76`) → Lösch-Bremse greift bei 325 Titeln
erst ab 109 (`:112-113`) → `_meldeLoeschungen` (`:210`, `:239-251`) →
`handle_delete` (`melo_cloud.py:374-394`) → `_entferne_datei_wenn_verwaist`
(`:356-372`) löscht `registry/<sid>` **und** über `loese_navidrome` (`:190-202`)
`navidrome/music/<sid>`. Für die **Datei** existiert kein Grabstein. Der
Reparaturweg ist verifiziert kaputt: der überlebende `registry.sha256` erzwingt
den Dedup-Zweig (`:265-267`), `shutil.move` steht nur im Neu-Zweig (`:289`),
`os.remove(tmp)` verwirft die Bytes (`:312`), `/download` antwortet dauerhaft 404
(`:1400-1408`). Alle drei denkbaren Bruchstellen sind ausgeschlossen
(Fremdnutzer-Schutz greift nie — Live-DB `Baka|325|0`; `users/` ist leer; beide
Pfade auf `/dev/vda3`, also echte Hardlinks). **Bestandsfehler, nicht von dieser
Spec verursacht** — Kalibrierung: `SUM(deleted) = 0`, die Kette wurde noch nie
ausgelöst.
**0.3 Die Haupt-Empfehlung des Panels schafft selbst einen neuen Löschpfad.
[VERIFIZIERT] [PLAN-RISIKO, neu geschaffen]**
Das ist der wichtigste Einzelbefund dieses Reviews und er richtet sich gegen das
Panel, nicht gegen die Spec. Das Panel konvergiert in Runde 2 auf den
Basis-Snapshot mit
`neu = (lokal server) (Basis \ lokal) (Basis \ server)`.
Ich habe nachgerechnet: Liefert `GET /favorites` fälschlich `∅` — und genau das
tut `parseFavoriten` heute bei jedem `{"status":"error"}` im 200er-Körper
(`melo_cloud_service.dart:117-124`; sechs Handler desselben Servers antworten so:
`melo_cloud.py:418, 491, 545, 766, 1021, 1118`; der Router verdrahtet für
`GET /favorites` hart 200: `:1292-1293`) —, dann wird
`Basis \ server = Basis` und die Formel kollabiert zu `neu = lokal Basis`:
**der gesamte bestätigte Bestand wird gelöscht, lokal und über den Delta-Push
auch auf dem Server und damit auf allen Geräten.**
Der Basis-Snapshot ist damit die **einzige Maßnahme im gesamten Bericht, die
einen neuen Datenverlustpfad einführt.** DA und Risk haben das je einmal am Rand
notiert; beide Phase-6-Zusammenfassungen führen es nicht als Finding. Es ist
tragend für mein Urteil in Abschnitt 1.
**0.4 Was die Verifikation dem Panel abspricht. [VERIFIZIERT]**
- „Auswahl-Modus in Meine Musik' existiert so nicht" (DA) — **widerlegt**:
`my_music_screen.dart:120``SortableSongList`
(`lib/shared/sortable_song_list.dart:46-54, 194-196`) mit vollem
Auswahl-Modus. Bleibt ein reines Scoping-Thema (die Aktion erschiene in fünf
Ansichten).
- „Pro-Song-Lade-Loop bringt weder Fortschritt noch Doppel-Lauf-Schutz mit"
(Feasibility) — **widerlegt**: `DownloadService.lade(List<SubsonicSong>)`
(`lib/services/download_service.dart:72-111`) ist öffentlich und bringt
Doppel-Lauf-Schutz (`:73`), Verbindungsprüfung (`:77-84`) und Fortschritt
(`:85-104`) mit. **Die Spec hat hier recht, das Panel nicht.**
- Worker-Pool-Erschöpfung (Risk K1) — vom Panel selbst korrekt zurückgezogen
(`ThreadingMixIn` ohne Pool, `melo_cloud.py:1449-1453`).
- „alle 33 `handle_*`" (Feasibility) — es sind 32. Ohne Folge.
**0.5 Was die Verifikation dem Panel *hinzufügt*. [VERIFIZIERT]**
`melo_cloud.py:505` löscht `user_playlist_songs` für **jede** `playlist_id` ohne
`user`-Bedingung. Das ist nicht nur ein falsches Erfolgssignal, sondern echte
Fremddaten-Löschung. Heute folgenlos (0 Playlisten), aber die Spec befördert
genau diesen Endpunkt zum Regelpfad.
**0.6 Severity-Dämpfung angewandt.**
Ein reines [PLAN-RISIKO], das eine sorgfältige Implementierung vermeiden kann,
ist höchstens P1. Ein [BESTANDSFEHLER], den diese Spec nicht verursacht, ist
Backlog-Punkt, kein Blocker. Nach diesem Maßstab bleibt von der Panel-Liste:
**0 × P0, 10 × P1, 8 × P2, 4 × P3.** Die Dringlichkeitsrhetorik des Panels ist
durch den Live-Stand entkräftet: ein Nutzer mit Songs, 0 gelöschte, 0 Favoriten,
0 Playlisten.
---
## 1. Entscheidung über die verbliebenen Meinungsverschiedenheiten
### 1.1 Merge-Mechanismus: Union vs. Basis-Snapshot vs. Drei-Wege-Merge
**Beide Lager haben in ihrer Diagnose recht und in ihrer Therapie unrecht. Ich
entscheide für eine dritte Option, die keines der beiden vorgeschlagen hat.**
Was feststeht:
- **Union kann „entfernt" nicht ausdrücken. [VERIFIZIERT]** Der Zyklus des
Sync-Spezialisten hält, und er ist schärfer als die Spec zugibt: A ent-favorisiert
**online**, Server ∅; B (das seither nicht synchronisiert hat, also praktisch
immer) synchronisiert, `Union({X}, ∅) = {X}`, POSTet `{X}`; A holt X beim
nächsten Lauf zurück. Die Spec-Zusage in Z. 108-112 („Ent-Favorisierungen
propagieren über den Sofort-Push (online)") ist damit **falsch**, nicht nur
unvollständig. Sie muss weg.
- **Der Basis-Snapshot heilt das, führt aber 0.3 ein.** Dazu braucht er
kontogebundenen Zustand, der beim Abmelden mitgelöscht werden muss —
`BakaAuth.abmelden` (`lib/services/baka_auth.dart:114-120`) räumt heute nichts
ab, und ein Token-Refresh gibt es auch nicht. Das ist neue Persistenz mit einer
neuen Aufräumpflicht für drei Nutzer mit null Favoriten.
**Was das Panel übersehen hat (mein eigener Befund, siehe auch Abschnitt 3):**
Die eigentliche Gefahrenquelle ist gar nicht die Union — es ist der
**Full-Replace-`POST /favorites`**. Solange die Sync-Phase den kompletten
Server-Stand ersetzt, hängt die Datensicherheit an einer *Regel*
(GET-vor-POST), und jede Lücke in dieser Regel (`parseFavoriten`!) wird sofort
zum Datenverlust. Streicht man den Full-Replace, verschwindet die ganze
Fehlerklasse **strukturell**.
**URTEIL — additiver Delta-Abgleich, kein Voll-Ersatz, kein Basis-Snapshot:**
| | schreibt Server | schreibt lokal | neuer Zustand | neuer Löschpfad |
|---|---|---|---|---|
| heute | `POST /favorites` (Voll-Ersatz) | — | — | **ja, der Bug** |
| Spec (Union + Voll-Ersatz) | `POST /favorites` | ja | — | ja, sobald GET `∅` täuscht |
| Panel (Basis-Snapshot) | Delta-Push | ja | Basis je Datentyp, kontogebunden | **ja (0.3)** |
| **Urteil (additiv)** | `POST /favorites/toggle` `set:true`, nur für `lokal \ server` | nur additiv | **keiner** | **keiner** |
Der Endpunkt existiert und ist deterministisch — eigene Prüfung:
`melo_cloud.py:1298-1302` (Route, `set` aus dem JSON-Körper) →
`handle_favorites_toggle` (`:644-688`), `set_state is True/False` erzwingt den
Zielzustand. Im Client fehlt nur die Methode (kein Treffer für `toggle` in
`melo_cloud_service.dart`).
Warum das die richtige Antwort für dieses Projekt ist:
1. **Ziel 1 wird vollständig erreicht** („kein Gerät überschreibt mehr die
Server-Favoriten") — und zwar durch Konstruktion statt durch eine Regel. Es
gibt danach keinen Codepfad mehr, der den Server-Stand ersetzen kann.
2. **Kein neuer Zustand.** Kein Basis-Set, keine Aufräumpflicht beim Abmelden,
keine Mengenalgebra. Weniger Teile als die Spec heute hat, nicht mehr.
3. **Der Fehlerpfad ist selbstheilend.** Täuscht der GET eine leere Menge vor,
pusht das Gerät seine lokalen Favoriten additiv hoch — harmlos und
idempotent. Beim Basis-Snapshot wäre derselbe Fehler eine geräteübergreifende
Massenlöschung.
4. **Der spätere Ausbau ist nicht verbaut.** Wenn sich in der Praxis zeigt, dass
Ent-Favorisieren wirklich stört, ist der Basis-Snapshot ein *Aufsatz* auf
denselben Schreibweg (`set:false` statt `set:true`) — kein Umbau.
5. **Preis, ehrlich benannt:** Ent-Favorisieren bleibt geräteübergreifend
unzuverlässig. Das ist dieselbe Einschränkung, die die Spec ohnehin schon
akzeptiert — nur muss sie **richtig** beschrieben werden (nicht „offline",
sondern „auch online, sobald ein zweites Gerät den alten Stand hält").
**Damit ist die Auftraggeber-Entscheidung „Union-Merge" nicht überstimmt,
sondern präzisiert:** die Vereinigungs-*Semantik* bleibt genau wie entschieden;
nur der *Schreibweg* wechselt vom Voll-Ersatz auf additive Pushes. Die
Panel-Empfehlung Basis-Snapshot wird **abgelehnt** — mit der Begründung aus 0.3.
Sie kommt als benannter Backlog-Punkt ins Dokument, nicht als Bestandteil.
### 1.2 SSE streichen — ja
**Das Panel steht 3:2 für Behalten (mit acht Härtungs-Bausteinen). Ich
entscheide gegen die Mehrheit, aber mit einem Argument, das niemand widerlegt
hat.** Nicht „SSE ist schlecht", sondern: **die eigene Vorbedingung von SSE ist
in dieser Spec nicht erfüllt.**
- Der beworbene Haupt-Nutzen („neuer Song erscheint in Sekunden auf dem anderen
Gerät") braucht das `song_upload`-Event. Das feuert `melo_cloud.py` nicht
(`_emit_event` nur bei `:393 song_delete`, `:410 song_update`,
`:687 song_favorite`**[VERIFIZIERT]**), und die Spec lagert es
ausdrücklich aus (Z. 211-215, 276-278). **Am Auslieferungstag kann SSE genau
das nicht, wofür es gebaut wird.**
- Die drei vorhandenen Kanäle haben heute keinen Adressaten: Live-DB `Baka|325`,
0 gelöschte, 0 Favoriten, 0 Playlisten. **[VERIFIZIERT]**
- Dem stehen acht eigene, je einzeln belegte Fehlerklassen gegenüber (eigener
`http.Client` wegen `melo_cloud_service.dart:82-83`; Heartbeat-Watchdog gegen
den halboffenen Socket nach WLAN↔LTE; 401-Dauerabbruch, weil `baka_auth.dart`
kein Refresh hat; Selbst-Echo-Unterdrückung, weil `melo_realtime.py:56-70` an
alle Queues des Nutzers ohne Absenderkennung broadcastet; Timer-Abbau bei
`paused`; Debounce; Backoff; Nachhol-Anstoß). Kein anderes Ziel der Spec trägt
annähernd so viel Fehleroberfläche.
- Dieselbe Spec lehnt Delta-Protokoll, Hintergrund-Sync und Chunked-Upload mit
YAGNI ab. SSE ist die einzige Ausnahme von der eigenen Regel.
- Der ehrliche Preis ist klein und vom Panel selbst festgestellt: Es gibt
**keinen** billigen Poll-Ersatz (`automatisch()` läuft nur aus `initState` und
`resumed`, `main.dart:175`, `:203`, kein Timer — **[VERIFIZIERT]**). Der Tausch
lautet: Änderungen anderer Geräte erscheinen beim nächsten App-Start oder
Zurückkehren — **genau wie heute**. Für drei private Nutzer ist das der
richtige Preis für acht entfallende Fehlerklassen.
**URTEIL: SSE (Ziel 6, Feature 6, `echtzeit_sync.dart`) wird aus dieser Spec
herausgenommen und als eigene, spätere Stufe geführt — Vorbedingung: das
`song_upload`-Event steht.** Das ist keine Verwerfung, sondern Reihenfolge. Fällt
mit weg: der Parameter `erzwinge` (wäre ohnehin wirkungslos — die Drossel sitzt
in `automatisch()` `:166-170`, nicht in `synchronisiere()` `:174-180`,
**[VERIFIZIERT]**).
### 1.3 Playlist-Sync drin lassen oder herausschneiden — reduzieren
Risk formuliert es korrekt: entweder vier Semantik-Entscheidungen treffen oder
herausschneiden; das Weiterlaufen im jetzigen Zustand nicht. Der Befundstand:
- Namenszuordnung ist keine Funktion (gleichnamige Playlisten per Knopfdruck
erzeugbar, `playlist_service.dart:75-109`).
- Kein Rename-Endpunkt — **[VERIFIZIERT]**, vollständiger Routing-Block
`melo_cloud.py:1251-1284` gelesen, kein `PUT`/`PATCH` auf die Playlist selbst.
Die Spec behauptet in Z. 129-130 das Gegenteil.
- `POST /playlists` nicht idempotent, kein Unique (`:488-500`).
- `handle_playlist_delete` löscht `user_playlist_songs` ohne Owner-Prüfung
(`:505`) — echter Server-Bug.
- „Längerer Stand gewinnt" ist bei Gleichstand undefiniert; `updatedAtMs` wird
app-seitig nur in `createPlaylist`/`deletePlaylist` gepflegt.
- N+1-Requests: `handle_playlist_list` liefert nur `song_count`.
- Live-Stand: **0 Playlisten auf dem Server.**
Das ist kein Nachschärf-Fall, sondern ein Entwurfsfall. Es ist aber auch kein
Grund, das Feature ganz zu streichen — der Nutzen (Playlisten überleben ein
zurückgesetztes Handy) ist real und billig zu haben, **wenn man auf den
beidseitigen Merge verzichtet.**
**URTEIL: Playlist-Sync Stufe 1 = einseitige Sicherung.**
Die App pusht lokale Playlisten-Änderungen online an den Server und merkt sich
die vom Server vergebene cloudId. **Kein Rück-Merge.** Server-Playlisten werden
nur dann lokal angelegt, wenn die lokale Playlisten-Tabelle **leer** ist
(Neuinstallation / Wiederherstellung). Damit entfallen ersatzlos: Namenszuordnung,
Reihenfolge-Schiedsrichter, Gleichstands-Regel, Tombstone-Semantik und die
Abhängigkeit vom fehlenden Rename-Endpunkt — die Identität stammt **immer** aus
dem eigenen `POST /playlists` und ist damit per Konstruktion stabil
(`user_playlists.id` ist `INTEGER PRIMARY KEY AUTOINCREMENT`).
Vorbedingung bleibt: `melo_cloud.py:505` (Owner-Prüfung) muss serverseitig
geflickt sein, bevor die App diesen Endpunkt regelmäßig benutzt.
Der beidseitige Playlist-Merge ist eine eigene, spätere Spec.
(Wer noch weiter reduzieren will: ganz herausschneiden ist ebenfalls vertretbar
und kostet heute nichts.)
---
## 2. Prüfung der Konsens-Punkte (Konsens kann geschlossen falsch sein)
| Konsens-Punkt | Urteil |
|---|---|
| „Union muss durch einen Basis-Snapshot ersetzt werden" (Runde 2, alle fünf) | **FALSCH in der Therapie.** Diagnose hält, Empfehlung nicht — siehe 0.3 und 1.1. Der Konsens entstand, weil die Debatte nur zwei Kandidaten kannte (Outbox vs. Basis-Snapshot) und beide *in Kombination mit dem Full-Replace-POST* dachte. |
| „DAs Outbox löst nur ein Drittel" (Runde 1, mit Gegenbeispiel akzeptiert) | **Zu früh geschlossen.** Das Gegenbeispiel lautet wörtlich „Offline-Gerät B resurrects **via Union**" — es setzt voraus, dass die Union bleibt. Es widerlegt die Outbox nur in dieser Paarung. Für mein Urteil ist das nicht tragend (ich brauche keine Outbox), aber es zeigt den Denkfehler des Konsenses. |
| „Die GET-vor-POST-Regel ist die beste Stelle der Spec" (Phase 3, alle fünf) | **FALSCH und vom Panel selbst korrigiert.** Sie ist eine Regel über ein nicht beobachtbares Prädikat. Richtig ist Flutters Umkehrung in Runde 2. Meine Konsequenz geht weiter: eine Regel, deren Verletzung Datenverlust bedeutet, gehört durch eine Konstruktion ersetzt, die den Verlust unmöglich macht (1.1). |
| „Die Testliste trägt" (7× erwähnt, stets lobend) | **FALSCH.** Der Auditor hat recht: sie nennt ausschließlich neue Tests. Zwei Bestandstests stehen der Spec aktiv im Weg (`melo_cloud_service_test.dart:98-101`, `sync_service_test.dart:166-202`), und der Sofort-Push — der einzige Online-Löschkanal der Spec — hat keinen einzigen Eintrag. |
| „Der Löschpfad ist Bestand, P1 für die Spec, P0 im Backlog" | **RICHTIG.** Die einzige Panel-Einstufung, die den Maßstab exakt trifft. Bestätigt. |
| „Die Architektur wird von keinem Befund angegriffen" | **RICHTIG.** `sync_merge.dart` als reine Funktionsdatei ohne I/O ist der richtige Ort — auch für den additiven Abgleich. Der Dateischnitt bleibt (abzüglich `echtzeit_sync.dart`). |
| „Die Nicht-Ziele sind diszipliniert und begründet" | **RICHTIG — mit einer Ausnahme, die das Panel benannt hat:** SSE bricht die eigene YAGNI-Regel. Nach 1.2 ist die Ausnahme beseitigt und der Konsens wird nachträglich vollständig richtig. |
| „`flutter_local_notifications` ist der einzige harte Blocker" | **RICHTIG**, mit Feasibilitys Korrektur: `multiDexEnabled` ist bei `minSdk 24` (`FlutterExtension.kt:26`) gegenstandslos und fällt aus der Forderung. |
| „Die Phasenreihenfolge stimmt" | **RICHTIG**, gegen `sync_service.dart:207-215` verifiziert. Aber Z. 225 („Zeitstempel am Ende") widerspricht Z. 250 („Snapshot VOR dem Listen") — beides ist erfüllbar, die Spec sagt es nur nicht. |
---
## 3. Eigener Lücken-Scan — was auch der Auditor nicht gesehen hat
**L1 [P1] [EINZELQUELLE: Richter] [PLAN-RISIKO] — Der Full-Replace-POST, nicht
die Union, ist die Wurzel.**
Fünf Reviewer und der Auditor haben zwei Runden über die *Mengenformel* debattiert
und dabei den *Schreibweg* als gegeben behandelt. Kein Beitrag prüft, was
passiert, wenn man `POST /favorites` einfach nicht mehr benutzt. Ergebnis:
Ziel 1 wird dann strukturell erreicht, die Abhängigkeit der Datensicherheit von
`parseFavoriten` entfällt, und es entsteht kein neuer Zustand. Risk hat den
Delta-Push als Bestandteil (c) seines Basis-Snapshot-Pakets vorgeschlagen — dass
Bestandteil (c) **allein** genügt, hat niemand geprüft. Ausführlich in 1.1.
**L2 [P2] [EINZELQUELLE: Richter] [BESTANDSFEHLER] — Es gibt zwei parallele
Favoriten-Systeme in derselben Oberfläche, und die Spec kennt nur eines.**
`lib/shared/server_favorite_button.dart:42` setzt Favoriten über
`navidrome.setFavorite(navidromeId)` — also im **Navidrome**, nicht in der
lokalen `Favorites`-Tabelle und nicht in der Melo-Cloud.
`lib/player/now_playing_screen.dart:275-279` zeigt für lokale Songs
`FavoriteButton` (lokal) und für Server-Titel `ServerFavoriteButton`
(Navidrome) — **dasselbe Herz-Symbol, zwei getrennte Systeme.**
Gegenprobe: `ServerFavoriteButton` / `server_favorite_button` kommt in **keiner**
der 25 State-Dateien und nicht in der Spec vor (`grep -rln` → kein Treffer).
Der dritte Kanal, `PlaylistService.syncFavoritesFromServer()`
(`lib/library/playlist_service.dart:52-71`), wurde von Risk (Phase 3, `:303-308`)
und DA (Phase 3, `:297-299`) als toter Code erkannt (`db.songExists(navidromeId)`
gegen lokale UUIDs) — fiel aber aus allen fünf Schlussurteilen heraus.
Folge: Ein Nutzer, der im Now-Playing-Screen ein Herz antippt, kann je nach
Titelquelle in zwei völlig verschiedenen Systemen landen; die Spec verspricht
Favoriten-Sync, deckt aber nur eines ab. Das ist eine **Geltungsbereichs-Frage,
die in die Spec gehört**, nicht in den Kopf des Umsetzenden.
**L3 [P2] [EINZELQUELLE: Richter] [PLAN-RISIKO] — Die Pull-Richtung hat keine
Regel für unauflösbare Server-Favoriten.**
Die Spec sagt „fehlende Favoriten lokal setzen" (Z. 99) und schweigt dazu, was
bei einer cloudId passiert, die sich lokal auf keinen Song abbilden lässt
(Download fehlgeschlagen, Song noch nicht geladen). `Favorites.songId` verweist
auf `Songs.id` (`database.dart:102-107`); ein Favorit ohne Song wäre über
`watchFavorites()` (innerJoin, `:371-375`) unsichtbar, würde aber über
`favoriteSongIds()` (`:534-536`) ewig mitgeschleppt und wieder hochgepusht.
Nebenbefund am selben Ort: `favoriteSongIds()` liefert auch Favoriten
**getombsteter** Songs, weil `allSongs()` (`:216`) Grabsteine einschließt — ein
lokal gelöschter Titel wird also weiter als Favorit gemeldet, während derselbe
Lauf ihn am Server löscht. Unter dem additiven Abgleich harmlos, unter jedem
Voll-Ersatz nicht.
**L4 [P2] [EINZELQUELLE: Richter] — Der additive Delta-Push feuert N SSE-Events.**
`handle_favorites_toggle` emittiert bei **jedem** Aufruf `song_favorite`
(`melo_cloud.py:687`). Beim ersten Lauf nach der Umstellung sind das so viele
Events wie lokale Favoriten. Ohne SSE in der App (Urteil 1.2) folgenlos — aber es
gehört als Vorbedingung notiert, falls SSE später kommt: das Selbst-Echo-Fenster
muss dann existieren, **bevor** der Delta-Push zum Regelpfad wird.
**L5 [P3] [EINZELQUELLE: Richter] — `handle_favorites_get` liefert
`favorited_at`, der Client wirft es weg.**
`melo_cloud.py:620` gibt je Favorit `favorited_at` zurück; `parseFavoriten` liest
nur `['id']`. Lokal existiert das Gegenstück als `Favorites.createdAtMs`
(`database.dart:103`). Kein Handlungsbedarf für diese Spec (ohne Tombstones auf
beiden Seiten trägt ein Zeitstempel keine Lösch-Entscheidung), aber es ist die
Zutat, die ein späterer echter Merge bräuchte — und sie ist bereits da. Ein Satz
im Backlog spart der Zukunft eine Server-Änderung.
---
## 4. Epistemik- und Typ-Labels (Sammelübersicht)
| # | Befund | Epistemik | Typ | Severity |
|---|---|---|---|---|
| 1 | `parseFavoriten` verwandelt Fehlerantworten in `[]`; `favoriten()` hat null Aufrufer | [VERIFIZIERT] | [PLAN-RISIKO] | P1 |
| 2 | Union kann „entfernt" nicht ausdrücken; Spec-Zusage Z. 108-112 ist falsch | [VERIFIZIERT] | [PLAN-RISIKO] | P1 |
| 3 | Basis-Snapshot schafft neuen Löschpfad (Panel-Empfehlung) | [VERIFIZIERT] | [PLAN-RISIKO, neu geschaffen] | P1 — **abgelehnt** |
| 4 | Full-Replace-`POST /favorites` ist die eigentliche Wurzel | [EINZELQUELLE: Richter] | [PLAN-RISIKO] | P1 |
| 5 | Irreversibler Löschpfad bis in die Navidrome-Bibliothek | [VERIFIZIERT] [KONSENS] | [BESTANDSFEHLER] | P1 Spec / **P0 Backlog** |
| 6 | Playlist-Identität instabil (4 offene Entscheidungen) | [VERIFIZIERT] [KONSENS] | [PLAN-RISIKO] | P1 |
| 7 | `melo_cloud.py:505` löscht fremde `user_playlist_songs` ohne Owner-Prüfung | [VERIFIZIERT] | [BESTANDSFEHLER] | P1 Server-Backlog, **Vorbedingung** |
| 8 | Kein Playlist-Rename-Endpunkt (Spec behauptet das Gegenteil) | [VERIFIZIERT] | [PLAN-RISIKO] | P1 |
| 9 | Fünf „bestehende Schutzmechanismen", die es nicht gibt | [VERIFIZIERT] | [BESTANDSFEHLER] + [PLAN-RISIKO] | P1 |
| 10 | Gradle-Desugaring für `flutter_local_notifications` | [VERIFIZIERT] [KONSENS] | [PLAN-RISIKO] | P1 (harter Blocker) |
| 11 | SSE-Hauptnutzen hängt am ausgelagerten `song_upload`-Event | [VERIFIZIERT] | [PLAN-RISIKO] | P1 |
| 12 | Bestandstest `melo_cloud_service_test.dart:98-101` zementiert den Bug | [VERIFIZIERT] (Verifikation) | [BESTANDSFEHLER im Test] | P1 |
| 13 | Bestandstest `sync_service_test.dart:166-202` + Catch-All-Mock `:196` | [VERIFIZIERT] (Auditor) | [BESTANDSFEHLER im Test] | P1 |
| 14 | Sofort-Push ohne einen einzigen Testeintrag | [VERIFIZIERT] (Auditor) | [PLAN-RISIKO] | P1 |
| 15 | Zwei parallele Favoriten-Systeme (`ServerFavoriteButton`) | [EINZELQUELLE: Richter] | [BESTANDSFEHLER] | P2 |
| 16 | Pull-Richtung ohne Regel für unauflösbare cloudIds | [EINZELQUELLE: Richter] | [PLAN-RISIKO] | P2 |
| 17 | Auswahl-Upload nicht abbrechbar | [VERIFIZIERT] (Auditor) | [PLAN-RISIKO] | P2 |
| 18 | 30er-Rückfrage strukturell wirkungslos; kein Platz-Check | [VERIFIZIERT] (Auditor) | [PLAN-RISIKO] | P2 |
| 19 | Doppelspeicherung Download × Abspiel-Cache | [VERIFIZIERT] (Auditor) | [PLAN-RISIKO] | P2 |
| 20 | Zeitstempel × Auswahl-Upload × 24-h-Bericht offen | [VERIFIZIERT] (Auditor) | [PLAN-RISIKO] | P2 |
| 21 | Bericht: `letzterLauf == null` und „erfolgreich" undefiniert | [VERIFIZIERT] (Auditor) | [PLAN-RISIKO] | P2 |
| 22 | Offline-Modus von keinem neuen Netz-Verbraucher beachtet | [VERIFIZIERT] (Auditor) | [PLAN-RISIKO] | P2 |
| 23 | Navidrome-Playlist-Import nicht idempotent („🌐"-Duplikate) | [VERIFIZIERT] | [BESTANDSFEHLER] | P2 |
| 24 | `_LadeKnopf` ist privat und in einer anderen Datei | [VERIFIZIERT] | [PLAN-RISIKO] | P2 |
| 25 | Schema-Divergenz `ist_korrupt` / `uq_user_song` | [VERIFIZIERT] | [BESTANDSFEHLER] | P2 Backlog |
| 26 | `server_titel_screen._geladen`-Einmal-Snapshot | [VERIFIZIERT] | [BESTANDSFEHLER] | P2 |
| 27 | Widerspruch Z. 225 ↔ Z. 250 (Zeitstempel) | [VERIFIZIERT] (Auditor) | [PLAN-RISIKO] | P3 |
| 28 | „2-GB-Abspiel-Cache" ist ein Standardwert (08192 MB) | [VERIFIZIERT] (Auditor) | [PLAN-RISIKO] | P3 |
| 29 | Kein Migrations-Downgrade-Pfad (Schema 10 → 11) | [VERIFIZIERT] (Auditor) | [PLAN-RISIKO] | P3 |
| 30 | `favorited_at` / `createdAtMs` ungenutzt | [EINZELQUELLE: Richter] | [PLAN-RISIKO] | P3 |
| — | „Auswahl-Modus existiert nicht" (DA) | [BESTRITTEN → widerlegt] | — | entfällt |
| — | „Pro-Song-Loop ohne Fortschritt/Schutz" (Feasibility) | [BESTRITTEN → widerlegt] | — | entfällt |
| — | Worker-Pool-Erschöpfung (Risk K1) | [BESTRITTEN → zurückgezogen] | — | entfällt |
---
## 5. Finaler Score und Verdikt
# Score: **6 / 10**
Über dem Panel-Mittel (5,2), weil die Verifikation zwei tragende Säulen der
Panel-Kritik entfernt hat: es gibt **kein P0 im Geltungsbereich**, und die
zentrale Ersatz-Empfehlung des Panels ist selbst gefährlicher als das, was sie
ersetzen soll (0.3). Die beiden „ÜBERARBEITEN"-Stimmen begründen sich fast
vollständig aus einer Therapie, die ich ablehne.
Nicht höher als 6, weil das Dokument drei belegte Substanzmängel hat:
eine **falsche Garantie** (Z. 108-112 — Ent-Favorisierungen propagieren nicht),
**fünf zu optimistische Bestandsbehauptungen** (Rename-Endpunkt, `_LadeKnopf`,
„verzögert", `erzwinge`, „vollständig" in Z. 17) und einen **Umfang, den es
nicht rechtfertigen kann** (SSE ohne seine eigene Vorbedingung, Playlist-Sync mit
vier offenen Semantik-Entscheidungen, eine Dependency, die den einzigen harten
Build-Blocker der ganzen Planung erzwingt).
# Verdikt: **SPEC NACHSCHÄRFEN DANN FREIGEBEN**
Begründung gegen „ÜBERARBEITEN": Jede von mir geforderte Änderung ist entweder
(a) die Korrektur eines faktisch falschen Satzes, (b) eine **Reduktion** des
Umfangs — Abschnitte streichen oder verschieben ist keine Neuschrift, oder
(c) **ein** Mechanismus-Tausch, der das Dokument *kleiner* macht (additiver
Delta-Push statt Voll-Ersatz = weniger bewegliche Teile als heute). Rahmen,
Zerlegung, Nicht-Ziele, Architektur (`sync_merge.dart` als reine Funktionsdatei),
Phasenreihenfolge und der Testansatz bleiben unangetastet und wurden von keinem
Reviewer angegriffen. Es gibt nichts wegzuwerfen.
Begründung gegen „FREIGEBEN": Die falsche Lösch-Garantie und die fünf
Bestandsbehauptungen würden den Umsetzenden nachweislich in die Irre führen; zwei
grüne Bestandstests stehen der Umsetzung aktiv im Weg; und eine Frage kann kein
Reviewer entscheiden (siehe A6).
**Bedingung:** Die nachgeschärfte Fassung wird **einmal kurz gegengelesen**,
bevor der Implementierungsplan entsteht — die Punkte A1, A5 und A7 ändern, *was*
gebaut wird, nicht nur *wie* es beschrieben ist.
---
## 6. AKTIONSLISTE — konkrete Änderungen an der Spec
Priorisiert. Jede Zeile ist so formuliert, dass sie direkt ins Dokument kann.
---
### A1 · P1 · [EINZELQUELLE: Richter] [PLAN-RISIKO] · §Feature 1, Punkt „Vereinigung beim Sync" (Z. 95-101) + §Risiken (Z. 252-254)
**Full-Replace-POST ersatzlos streichen. Der Abgleich schreibt nur noch additiv.**
> Die Sync-Phase benutzt **nie** `POST /favorites` (Voll-Ersatz). Sie schreibt
> ausschließlich additiv:
> 1. `GET /favorites` → `server` (Menge von cloudIds).
> 2. Für jede cloudId in `lokal \ server`: `POST /favorites/toggle` mit
> `{"song_id": …, "set": true}` (existiert und ist deterministisch:
> `melo_cloud.py:1298-1302` → `handle_favorites_toggle:644-688`; die
> Client-Methode ist neu).
> 3. Für jede cloudId in `server \ lokal`: lokal Favorit setzen, sofern der Song
> lokal auflösbar ist.
> 4. **In keiner Richtung wird etwas entfernt.**
>
> `MeloCloudService.setzeFavoriten` (`melo_cloud_service.dart:268-280`) wird nicht
> mehr benutzt und entfällt.
>
> **Wirkung:** Der Datenverlust-Bug aus Ziel 1 ist danach **strukturell**
> unmöglich, nicht nur durch eine Regel verhindert — es existiert kein Codepfad
> mehr, der den Server-Stand ersetzen kann. Deckel: höchstens 200 Pushes je Lauf,
> der Rest im nächsten Lauf (die Pushes sind idempotent, ein Teilausfall heilt
> sich beim nächsten Lauf selbst).
Streichen in §Risiken: „`POST /favorites` bleibt technisch ein Voll-Ersatz — die
Sicherheit liegt allein in der GET-vor-POST-Regel." Ersetzen durch: „Die
Sicherheit liegt nicht mehr in einer Regel, sondern darin, dass kein
Voll-Ersatz-Aufruf mehr existiert."
---
### A2 · P1 · [VERIFIZIERT] [PLAN-RISIKO] · §Feature 1 „Sicherheitsregel" (Z. 102-105) + §Tests
**`parseFavoriten` härten — und den grünen Bestandstest mitändern.**
> Ein GET gilt nur als erfolgreich, wenn HTTP 200 **und** kein `error`-Schlüssel
> **und** `favorites` als Liste vorhanden ist. Ein fehlender `favorites`-Schlüssel
> ist ein **Fehler, keine leere Menge**; `parseFavoriten` wirft dann
> `CloudException`, genau wie `parseListe` (`melo_cloud_service.dart:99-100`) und
> `parseUpload` (`:111-112`) es bereits tun. Hintergrund: der Router verdrahtet
> für `GET /favorites` hart 200 (`melo_cloud.py:1292-1293`), und sechs Handler
> desselben Servers geben Fehler im 200er-Körper zurück (`:418, 491, 545, 766,
> 1021, 1118`).
>
> **Bestandsänderung, Teil des Arbeitspakets:**
> `test/services/melo_cloud_service_test.dart:98-101` prüft heute ausdrücklich
> das Gegenteil (`expect(parseFavoriten(jsonEncode({'status':'ok'})), isEmpty)`)
> und wird mit umgeschrieben. Das ist kein Regressionsfehler.
>
> Zwei Pflichttests: „200 mit Fehlerkörper → Phase übersprungen, kein Push" und
> „200 mit `favorites: []` → Phase läuft normal durch" (der zweite verhindert,
> dass der erste durch ein zu scharfes „wirf bei leer" trivial erfüllt wird).
---
### A3 · P1 · [VERIFIZIERT] [PLAN-RISIKO] · §Feature 1, letzter Punkt (Z. 108-112)
**Die falsche Lösch-Garantie streichen und durch die richtige Einschränkung ersetzen.**
Zu streichen: „Ent-Favorisierungen propagieren über den Sofort-Push (online) …
Offline entfernte Herzen kommen beim nächsten Sync zurück". Das ist falsch — es
betrifft auch **online** entfernte Herzen.
> **Bekannte Einschränkung (bewusst, dokumentiert):** Der Abgleich ist rein
> additiv. Ein Ent-Favorisieren wirkt sofort lokal und — online — auch auf dem
> Server, ist aber **nicht geräteübergreifend garantiert**: Hält ein zweites
> Gerät den Favoriten noch, bringt dessen nächster Sync ihn auf allen Geräten
> zurück. Das gilt für offline **und für online** entfernte Herzen. Bewusster
> Tausch, wie in v2: kein Datenverlust ist wichtiger als verlässliche
> Lösch-Propagation.
> Falls das in der Praxis stört: Folgeschritt „Basis-Snapshot" (Backlog). Er
> lässt sich später **ohne Umbau** ergänzen, weil der Schreibweg dann schon der
> Delta-Push ist — aus `set:true` wird zusätzlich `set:false`.
---
### A4 · P1 · [VERIFIZIERT] [PLAN-RISIKO] · §Risiken, neuer Unterabschnitt „Verworfene Alternativen"
**Den Basis-Snapshot ausdrücklich ablehnen und begründen (Gegen-Empfehlung zum Panel).**
> **Verworfen: Drei-Wege-Merge gegen einen persistierten Basis-Snapshot**
> (`neu = (lokal server) (Basis \ lokal) (Basis \ server)`). Er löst zwar
> die Lösch-Propagation, führt aber den **einzigen neuen Datenverlustpfad** der
> ganzen Planung ein: Liefert `GET /favorites` fälschlich eine leere Liste, wird
> `Basis \ server = Basis`, die Formel kollabiert zu `lokal Basis` und löscht
> den gesamten bestätigten Bestand — lokal **und** über den Delta-Push auf dem
> Server und damit auf allen Geräten. Zusätzlich braucht er kontogebundenen
> Zustand mit Aufräumpflicht beim Abmelden; `BakaAuth.abmelden`
> (`baka_auth.dart:114-120`) räumt heute nichts ab und es gibt kein
> Token-Refresh. Für drei Nutzer, 0 Favoriten und 0 Playlisten auf dem Server ist
> der additive Abgleich der bessere Tausch. Der Basis-Snapshot bleibt als
> Folgeschritt im Backlog.
---
### A5 · P1 · [VERIFIZIERT] [KONSENS] [PLAN-RISIKO] · §Ziel 4, §Feature 2 (Z. 114-140), §Nicht-Ziele, §Tests, §Offene Abhängigkeiten
**Playlist-Sync auf einseitige Sicherung reduzieren. Der beidseitige Merge wird eigene Spec.**
> **Playlist-Sync Stufe 1 = einseitige Sicherung.** Die App meldet lokale
> Playlisten-Änderungen (anlegen / Song hinzufügen / Song entfernen /
> Reihenfolge) online fire-and-forget an den Server und persistiert die vom
> Server vergebene cloudId (Drift-Migration `Playlists.cloudId TEXT NULL` bleibt).
> Es findet **kein Rück-Merge** statt: Server-Playlisten werden nur dann lokal
> angelegt, wenn die lokale Playlisten-Tabelle **leer** ist (Neuinstallation /
> Wiederherstellung).
>
> Damit entfallen ersatzlos: Namenszuordnung, Reihenfolge-Schiedsrichter
> („längerer Stand gewinnt"), Gleichstands-Regel, Tombstone-Semantik und die
> Abhängigkeit vom fehlenden Rename-Endpunkt. Die Identität stammt **immer** aus
> dem eigenen `POST /playlists` und ist stabil (`user_playlists.id` ist
> `INTEGER PRIMARY KEY AUTOINCREMENT`).
>
> **Nicht abgedeckt (dokumentiert):** Umbenennungen propagieren nicht.
> Playlisten-Änderungen auf einem zweiten Gerät erscheinen auf dem ersten nicht.
> Der beidseitige Playlist-Merge ist eine eigene, spätere Spec.
>
> **Vorbedingung (Server-Auftrag, blockierend):** `melo_cloud.py:505` löscht
> `user_playlist_songs` für jede `playlist_id` **ohne `user`-Bedingung** — echte
> Fremddaten-Löschung. Muss geflickt sein, bevor die App diesen Endpunkt
> regelmäßig benutzt.
*Ebenfalls vertretbar und heute kostenlos (0 Playlisten auf dem Server): Feature
2 ganz herausschneiden. Entscheidung liegt bei Dustin; das Weiterlaufen im
jetzigen Zustand (vier offene Semantik-Entscheidungen) ist es nicht.*
---
### A6 · P1 für die Spec / **P0 im Backlog** · [VERIFIZIERT] [KONSENS] [BESTANDSFEHLER] · §Kontext (Z. 15-17) + §Risiken
**Den irreversiblen Löschpfad benennen — und die eine Frage stellen, die kein Reviewer entscheiden kann.**
§Kontext, Korrektur des Wortes „vollständig":
> „… der Weg Handy→Server→Navidrome-Bibliothek existiert also bereits — **und er
> läuft auch rückwärts**: verschwindet die lokale Datei, meldet der Sync die
> Löschung, und der Server entfernt die Audiodatei aus Registry **und**
> Navidrome-Bibliothek."
§Risiken, neuer Absatz:
> **Irreversibler Löschpfad (Bestand, nicht von dieser Spec verursacht —
> Backlog-P0).** `markMissing` (`database.dart:237-245`) tombstoned jeden Titel,
> dessen Datei der Scan nicht findet — auch unbeabsichtigt (SD-Karte nicht
> eingehängt, Berechtigung entzogen, Dateimanager). `planeSync`
> (`sync_service.dart:68-76`) macht daraus eine Server-Löschung; die Lösch-Bremse
> (`:112-113`) greift bei 325 Titeln erst ab 109 — **1 bis 108 Löschungen laufen
> ungebremst**. Der Server löscht `registry/<sid>` und `navidrome/music/<sid>`
> (`melo_cloud.py:356-372`, `:190-202`); für die Datei gibt es keinen Grabstein.
> Erneutes Hochladen repariert es **nicht**: der überlebende `registry.sha256`
> erzwingt den Dedup-Zweig (`:265-267`), `shutil.move` steht nur im Neu-Zweig
> (`:289`), `os.remove(tmp)` verwirft die Bytes (`:312`), `/download` antwortet
> dauerhaft 404 (`:1400-1408`). Bisher nie ausgelöst (Live-DB: `SUM(deleted)=0`).
>
> **Rückfrage an Dustin — kein Reviewer kann das entscheiden: Ist das gewollt?**
> Bei „ja" ist es eine dokumentierte Eigenschaft. Bei „nein" gehört ein
> Server-Auftrag (Dedup-Zweig stellt die Datei wieder her, wenn
> `registry_pfad(sid)` leer ist) **vor** Feature 3 — denn Feature 3 gibt mehr
> Titeln eine cloudId und vergrößert damit die Angriffsfläche.
---
### A7 · P1 · [VERIFIZIERT] [PLAN-RISIKO] · §Ziel 6 + §Feature 6 (Z. 193-215) + §Architektur-Tabelle + §Tests + §Offene Abhängigkeiten
**SSE aus dieser Spec herausnehmen, als eigene spätere Stufe führen.**
Ziel 6 und Feature 6 entfallen; `echtzeit_sync.dart` fällt aus der
Architektur-Tabelle; die SSE-Tests fallen aus der Testliste. Neuer Eintrag unter
§Nicht-Ziele:
> **Kein Echtzeit-Sync (SSE) in dieser Stufe.** Der beworbene Haupt-Nutzen
> („neuer Song erscheint in Sekunden auf dem anderen Gerät") hängt am
> `song_upload`-Event, das `melo_cloud.py` heute nicht feuert (`_emit_event` nur
> bei `:393`, `:410`, `:687`) und das diese Spec ausdrücklich auslagert — am
> Auslieferungstag könnte SSE also genau das nicht, wofür es gebaut wird. Die
> drei vorhandenen Kanäle (`song_delete`, `song_update`, `song_favorite`) haben
> heute keinen Adressaten (Live-Stand: ein Nutzer mit Songs, 0 gelöschte,
> 0 Favoriten, 0 Playlisten). Dem stehen acht eigene Fehlerklassen gegenüber:
> eigener `http.Client` (der geteilte aus `melo_cloud_service.dart:82-83` würde
> beim `close()` laufende Sync-Requests mitreißen), Heartbeat-Watchdog gegen den
> halboffenen Socket nach WLAN↔LTE, dauerhafter 401-Ausstieg (`baka_auth.dart`
> hat kein Refresh), Selbst-Echo-Unterdrückung (`melo_realtime.py:56-70`
> broadcastet an alle Queues des Nutzers ohne Absenderkennung), Timer-Abbau bei
> `paused`, Debounce, Backoff, Nachhol-Anstoß.
> **Ehrlicher Preis:** Änderungen anderer Geräte erscheinen beim nächsten
> App-Start oder Zurückkehren — genau wie heute. Es gibt keinen billigeren
> Poll-Ersatz: `automatisch()` läuft nur aus `initState` und `resumed`
> (`main.dart:175`, `:203`), ein Timer existiert nicht.
> SSE wird eigene Stufe, sobald das `song_upload`-Event steht; die acht
> Bausteine gehören dann in **jene** Spec, nicht in den Plan.
Ebenfalls streichen: der Parameter `erzwinge` (Z. 201-202). Er wäre wirkungslos —
die 15-Minuten-Drossel sitzt in `automatisch()` (`sync_service.dart:166-170`),
nicht in `synchronisiere()` (`:174-180`).
---
### A8 · P1 · [VERIFIZIERT] [KONSENS] [PLAN-RISIKO] · §Ziel 5 + §Feature 5 (Z. 172-191) + §Architektur-Tabelle
**Notification halbieren: Bericht sofort, persistente Notification als eigene Stufe mit Beweis-Build.**
> **Stufe A (keine neue Dependency): Sync-Bericht „Was ist neu"** als In-App-Dialog.
> **Stufe B (eigenes Arbeitspaket): persistente Fortschritts-Notification.** Sie
> erzwingt `flutter_local_notifications` und damit
> `isCoreLibraryDesugaringEnabled = true` plus
> `coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")` in
> `android/app/build.gradle.kts` — die Datei hat heute überhaupt keinen
> `dependencies { }`-Block, der Gradle-Heap steht auf 2 GB
> (`gradle.properties:1`, dokumentierte OOM-Quelle), AGP ist 9.0.1
> (`settings.gradle.kts:22`) und android-37 ist ein Nachbau von 36.
> **Erstes Abnahmekriterium der Stufe B ist ein grüner
> `flutter build apk --target-platform android-arm64` nach dem `pub add` — vor
> jeder Zeile Notification-Logik.**
> `multiDexEnabled` entfällt aus der Forderung: bei `minSdkVersion = 24`
> (`FlutterExtension.kt:26`) ist es gegenstandslos.
> Mitzuentscheiden: Channel mit `Importance.low`, `onlyAlertOnce: true`, und ein
> `cancel()` beim Init gegen die nach App-Kill stehengebliebene
> Fortschritts-Notification.
---
### A9 · P1 · [VERIFIZIERT] [BESTANDSFEHLER] + [PLAN-RISIKO] · §Fehlerfälle (Z. 227-240)
**Den Abschnitt von Zusagen auf zu bauende Arbeit umstellen. Fünf „bestehende Schutzmechanismen" gibt es nicht.**
> Die folgenden Schutzmechanismen **existieren nicht** und sind Teil dieser
> Arbeit:
> - **Phasen-Isolation:** `synchronisiere()` hat heute **ein einziges** try/catch
> um alle Phasen (`sync_service.dart:188-228`). Jede neue Phase bekommt ihr
> eigenes try/catch mit definiertem Teil-Erfolg.
> - **„verzögert" gibt es nicht:** `if (_laeuft) return;` (`:175`) verwirft den
> Anstoß wortlos. Wer einen Nachlauf will, braucht ein `_nachziehen`-Flag, das
> im `finally` genau **einen** weiteren Lauf auslöst.
> - **Der Upload überlebt keinen Timeout:** `_ladeHoch` fängt nur
> `CloudException` (`:313`); der 120-s-Timeout
> (`melo_cloud_service.dart:180`) wirft `TimeoutException` und reißt den ganzen
> Lauf ab. Timeouts sind wie Einzelfehler zu behandeln.
> - **Die Drossel sitzt in `automatisch()`** (`:166-170`), nicht in
> `synchronisiere()` (`:174-180`) — siehe A7, `erzwinge` entfällt.
> - **Der Löschweg zum Server** wird in den Fehlerfällen nirgends beschrieben —
> siehe A6.
---
### A10 · P1 · [VERIFIZIERT] (Verifikation + Auditor) [BESTANDSFEHLER im Test] · §Tests (Z. 256-272)
**Testliste um den Bestand erweitern. Sie nennt heute ausschließlich neue Tests.**
> **Anzupassender Bestand:**
> - `test/services/melo_cloud_service_test.dart:98-101` — zementiert das
> `[]`-Verhalten, wird mit A2 umgeschrieben.
> - `test/services/sync_service_test.dart:166-202` („Favoriten werden mit ihren
> Server-IDs gemeldet") prüft heute genau das Full-Replace-Verhalten, das
> Ziel 1 beseitigt. Sein MockClient beantwortet **jeden** Pfad außer `/list`
> mit `{'status':'ok'}` (`:196`) — unter der neuen Regel liefert
> `GET /favorites` dort 200 ohne `favorites`-Schlüssel und die Phase wird
> übersprungen. Der Test wird auf den additiven Delta-Push umgeschrieben und
> bekommt eine **explizite** `/favorites`-Antwort.
> **Der Catch-All-Mock darf nicht aufgeweicht werden, um die neue
> Sicherheitsregel zu umgehen** — das ist der schnellste Weg zu Grün und der
> falsche.
> - Dasselbe gilt abgeschwächt für die übrigen sieben Tests derselben Datei.
>
> **Neu, heute nicht in der Liste:** `PlaylistService` — der Sofort-Push ist der
> einzige Online-Kanal der Spec und hat keinen einzigen Testeintrag.
---
### A11 · P2 · [VERIFIZIERT] [PLAN-RISIKO] · §Feature 2 (Z. 129-132), §Feature 4 (Z. 159-170)
**Drei „existiert bereits"-Behauptungen korrigieren — und zwei ausdrücklich bestätigen.**
§Feature 2:
> Endpunkte: `GET/POST/DELETE /playlists`, `GET /<id>`, `POST /<id>/songs`,
> `DELETE /<id>/songs/<sid>`, `PUT /<id>/positions` (`melo_cloud.py:1251-1284`).
> **Einen Rename-Endpunkt gibt es nicht** — kein `PUT`/`PATCH` auf die Playlist
> selbst; `handle_rename:749` betrifft Songs.
§Feature 4:
> `ladeEinzelnenTitel(song)` ruft den bestehenden **öffentlichen**
> `DownloadService.lade([song])` (`download_service.dart:72-111`) auf — der
> bringt Doppel-Lauf-Schutz (`:73`), Verbindungsprüfung (`:77-84`) und
> Fortschritts-Buchführung (`:85-104`) mit. **Nicht** `_ladeEinen` (`:113-139`)
> direkt verdrahten.
> `_LadeKnopf` ist eine **private** Klasse in
> `lib/downloads/downloads_screen.dart:419-429`, kein wiederverwendbares Widget —
> als Muster kopieren oder vorher extrahieren.
*Zur Klarstellung im Dokument nicht nötig, aber für den Umsetzenden relevant: Die
Spec-Behauptungen „der Pro-Song-Lade-Loop existiert bereits" und „der
Auswahl-Modus in Meine Musik' existiert" sind **beide richtig** — das Panel lag
hier falsch (`sortable_song_list.dart:46-54, 194-196`).*
---
### A12 · P2 · [EINZELQUELLE: Richter] [BESTANDSFEHLER] · §Feature 1, neuer Satz „Geltungsbereich"
**Die beiden parallelen Favoriten-Systeme benennen.**
> **Geltungsbereich:** Der Favoriten-Abgleich betrifft ausschließlich die lokale
> `Favorites`-Tabelle. Der Stern in der Server-Titel-Ansicht
> (`ServerFavoriteButton`, `lib/shared/server_favorite_button.dart:42`, benutzt in
> `now_playing_screen.dart:279`) ist ein **anderes** System — Navidrome-Starring
> über `navidrome.setFavorite(navidromeId)` — und wird von dieser Spec nicht
> angefasst. Beide erscheinen im Now-Playing-Screen als dasselbe Herz-Symbol;
> dass sie getrennt bleiben, ist eine Entscheidung, keine Auslassung.
> `PlaylistService.syncFavoritesFromServer()` (`playlist_service.dart:52-71`) ist
> ein dritter Kanal und heute wirkungslos (`db.songExists(navidromeId)` gegen
> lokale UUIDs) — er wird **nicht** Teil des Abgleichs.
---
### A13 · P2 · [EINZELQUELLE: Richter] [PLAN-RISIKO] · §Feature 1, Pull-Richtung
**Regel für unauflösbare Server-Favoriten festschreiben.**
> Server-Favoriten, deren cloudId sich lokal auf keinen Song abbilden lässt
> (Download fehlgeschlagen, Titel noch nicht geladen), werden **übersprungen,
> nicht gelöscht**: `Favorites.songId` verweist auf `Songs.id`
> (`database.dart:102-107`); ein Favorit ohne Song wäre über `watchFavorites()`
> (innerJoin, `:371-375`) unsichtbar, würde aber über `favoriteSongIds()`
> (`:534-536`) weiter mitgeschleppt und wieder hochgepusht. Der additive
> Abgleich schreibt sie deshalb weder lokal, noch entfernt er sie serverseitig.
> Hinweis am selben Ort: `favoriteSongIds()` liefert auch Favoriten
> **getombsteter** Songs (`allSongs()` schließt Grabsteine ein, `:216`).
---
### A14 · P2 · [VERIFIZIERT] (Auditor) [PLAN-RISIKO] · §Feature 3 (Z. 142-157) + §Feature 4 (Z. 159-170) + §Fehlerfälle
**Fünf Entscheidungen treffen, die die Spec heute dem Umsetzenden überlässt.**
> - **Abbrechen:** Der Auswahl-Upload bekommt `SyncService.abbrechen()` nach dem
> Muster von `DownloadService` (`:46`, `:66-68`, `:96`) — ein Flag, das die
> Schleife zwischen zwei Songs prüft. `SyncService` hat heute **keinerlei**
> Abbruchmöglichkeit (0 Treffer für `abbrech|cancel` in 376 Zeilen); ohne das
> ist ein versehentlich markierter 60-Titel-Upload nur durch App-Kill zu
> stoppen — und die Spec bewirbt den Album-Download in Z. 19 ausdrücklich mit
> „Fortschritt + Abbrechen".
> - **Zeitstempel:** `ladeAusgewaehlteHoch` schreibt `_letzterLauf` (`:215-217`)
> **nicht** — es ist kein Sync. Sonst unterdrückt ein manueller Upload 15
> Minuten Auto-Sync (`sollAutoSync`, `:120-121`) und verschiebt die 24-h-Uhr
> des Berichts; wer die App täglich zum Hochladen öffnet, sähe den Bericht nie.
> - **Kein Platz-Check, keine Schwelle beim Einzel-Song-Offline:** Die
> 30er-Rückfrage (`download_service.dart:13`, `:15`, `:17-21`, Aufrufer nur
> `downloads_screen.dart:460` und `:496`) ist bei einem Einzeltitel per
> Konstruktion wirkungslos, und einen Check auf freien Speicher gibt es in der
> ganzen App nicht. Bewusst akzeptiert: der Knopf lädt genau einen Titel.
> - **Doppelspeicherung:** Vor dem Netz-Download prüft `ladeEinzelnenTitel`, ob
> die Datei bereits im Abspiel-Cache liegt — sonst entsteht genau die
> Doppelspeicherung, die `audio_handler.dart:326-331` in der Gegenrichtung
> ausdrücklich verhindert („doppelter Platz und doppeltes Datenvolumen").
> Falls zu teuer: als bekannte Einschränkung dokumentieren, nicht schweigen.
> - **Offline-Modus:** Der Schalter (`lib/services/offline_mode.dart`,
> `settings_screen.dart:218-231`) wirkt heute nur auf die Wiedergabe
> (`main.dart:106-110`). Entscheidung festhalten: Auswahl-Upload und
> Einzel-Song-Offline **respektieren ihn nicht**, weil beides ausdrückliche
> Nutzeraktionen sind.
---
### A15 · P2 · [VERIFIZIERT] (Auditor) [PLAN-RISIKO] · §Feature 5, Sync-Bericht (Z. 187-191)
**Erstfall und „erfolgreich" definieren.**
> `letzterLauf == null` (Neuinstallation, nach Abmelden, nach App-Daten-Löschen)
> gilt **nicht** als fällig — sonst begrüßt der Bericht ein frisch installiertes
> Gerät mit „Willkommen zurück! 325 neue Songs". Achtung: `sollAutoSync`
> (`sync_service.dart:120-121`) behandelt `null` genau umgekehrt und ist hier
> **kein** Vorbild.
> „Erfolgreich" heißt: alle Phasen ohne geschluckten Fehler. Dafür führt
> `synchronisiere()` ein Flag, das jede Phase bei einem Fehlschlag löscht; der
> Bericht-Zeitstempel wird nur bei gesetztem Flag geschrieben. Heute schreibt
> `:215-217` denselben Zeitstempel, egal ob Phasen ausgefallen sind
> (`_meldeLoeschungen:243-252` schluckt Fehler).
---
### A16 · P3 · [VERIFIZIERT] (Auditor) · Kleinigkeiten, je ein Satz
> - **§Sync-Phasen Z. 225 ↔ §Risiken Z. 250:** „Der Zeitstempel wird **vor** dem
> Listen genommen und **nach** allen Phasen geschrieben." (Heute:
> `_letzterLauf = DateTime.now()` in `:215`, nach allen Phasen.)
> - **§Kontext Z. 19-20:** „automatischer Abspiel-Cache, Standard 2 GB,
> einstellbar 08192 MB" (`app_settings.dart:28`, `:31`).
> - **§Nicht-Ziele:** „Ein Rollback auf eine ältere App-Version ist nach der
> Schema-Migration nicht vorgesehen — `database.dart:154-192` kennt nur
> `onCreate` und `onUpgrade`."
> - **§Offene Abhängigkeiten:** Schema-Divergenz `ist_korrupt` / `uq_user_song`
> (nur in der Live-DB, nicht im `CREATE TABLE` des Skripts) als Backlog-Punkt —
> trifft nur wiederhergestellte oder Test-Instanzen.
> - **Backlog-Notiz:** `handle_favorites_get` liefert bereits `favorited_at`
> (`melo_cloud.py:620`), lokal existiert `Favorites.createdAtMs` — die Zutat
> für einen späteren echten Merge ist beidseitig vorhanden.
---
### A17 · P1 · [KONSENS] · §Neuer Abschnitt „Reihenfolge" (vor §Tests)
**Auslieferungsreihenfolge festschreiben — sie fehlt heute ganz.**
> 1. **Favoriten-Fix allein** (additiver Delta-Push, `parseFavoriten`,
> Testanpassung). Wartet auf **nichts**: kein Server-Auftrag, keine neue
> Dependency, keine Gradle-Änderung, kein neues UI-Muster. Einzige Stufe mit
> belegtem Datenverlust-Bezug.
> 2. Auswahl-Upload + Einzel-Song-Offline (UI, keine neue Dependency).
> 3. Sync-Bericht (Stufe A, kein Plugin).
> 4. Playlist-Sicherung Stufe 1 — **nach** dem Server-Fix `melo_cloud.py:505`.
> 5. Notification Stufe B — beginnt mit dem grünen Beweis-Build.
> 6. SSE — eigene Spec, nach dem `song_upload`-Event.
---
### §Offene Abhängigkeiten — geschlossene Liste (ersetzt Z. 274-281)
| # | Auftrag | Art | Blockiert |
|---|---|---|---|
| 1 | `melo_cloud.py:505` — Owner-Prüfung in `handle_playlist_delete` | **Voraussetzung** | A5 / Feature 2 |
| 2 | Datei-Wiederherstellung im Dedup-Zweig von `upload()` | **Voraussetzung, falls Antwort auf A6 „nein"** | Feature 3 |
| 3 | `song_upload`-SSE-Event | Voraussetzung für die spätere SSE-Stufe | nichts in dieser Spec |
| 4 | Playlist-Rename-Endpunkt (`PUT`/`PATCH`) | optional | nichts (A5 braucht ihn nicht mehr) |
| 5 | Playlist-Tombstones serverseitig | optional / Backlog | nichts |
---
## 7. Meta-Beobachtung
**Zur Spec-Qualität.** Das Dokument ist auffallend ehrlich in seinen Nicht-Zielen
und faktisch überwiegend korrekt — rund zwanzig Bestandsbehauptungen haben fünf
unabhängigen Prüfungen standgehalten, und zwei angebliche Fehler des Panels waren
in Wahrheit Fehler des Panels. Seine zwei echten Schwächen sind typisch für
Specs, die von jemandem geschrieben werden, der den Code gut kennt: Es
**verwechselt „ich weiß, wie das geht" mit „das ist schon da"** (fünf zu
optimistische Bestandssätze), und es **formuliert Sicherheit als Regel statt als
Konstruktion** („nie ohne erfolgreiches GET POSTen") — Regeln haben Lücken,
Konstruktionen nicht. Der größte Einzelmangel ist aber der Umfang: sechs Ziele,
von denen genau eines auf nichts wartet und einen belegten Bug behebt, während
die anderen fünf zusammen vier Server-Aufträge, eine Build-Umbauaktion und acht
Fehlerklassen mitbringen. Das ist kein Denkfehler, das ist fehlende
Reihenfolge — und sie ist mit einem Abschnitt behoben.
**Zum Panel-Prozess.** Der Prozess war stark, wo er üblicherweise schwach ist,
und schwach an einer Stelle, die der Prozess nicht vorsieht.
*Stark:* Die Beleglage ist außergewöhnlich — ~70 geprüfte Datei:Zeile-Belege,
**keine einzige Halluzination**, keine Fehlzuordnung. Mehrere Reviewer haben
eigene P0 aktiv falsifiziert (DA hat seine eigene Rettungshypothese widerlegt,
Risk hat sein eigenes P0 konzediert, Feasibility hat drei eigene Befunde fallen
lassen). Das ist das Gegenteil von Konsens-Drift.
*Schwach:* **Der Prozess prüft die Spec adversarial, aber die eigene Empfehlung
nur konsensual.** Sobald Runde 2 auf den Basis-Snapshot konvergiert war, hat
niemand ihn mehr mit derselben Härte angegriffen wie zuvor die Union — obwohl DA
und Risk den entscheidenden Einwand (0.3) je einmal notiert hatten. Beide
Phase-6-Zusammenfassungen führen ihn nicht als Finding. Ein Panel, das eine
Therapie empfiehlt, braucht eine Runde, in der die *Therapie* der
Review-Gegenstand ist. Erst die separate Verifikation hat das aufgedeckt — sie
ist der wertvollste Bestandteil dieses Verfahrens gewesen.
*Zweiter blinder Fleck:* Der Suchraum war zu klein. Zwei Runden über „Outbox oder
Basis-Snapshot" haben den Schreibweg (`POST /favorites` als Voll-Ersatz) als
gegeben behandelt. Die billigste Lösung lag außerhalb der Debattenachse — sie
bestand darin, einen Aufruf zu **streichen**, nicht einen Mechanismus zu bauen.
Das ist die wiederkehrende Blindstelle von Review-Panels: Sie sind darauf
trainiert, Lücken zu finden, und finden deshalb Dinge, die man **hinzufügen**
muss.
*Dritter Punkt:* Die Testschuld wurde erst vom Vollständigkeits-Auditor entdeckt.
Fünf Reviewer haben `sync_service.dart` zeilengenau seziert und `test/` nur
einmal betreten. Für ein Projekt mit TDD-Pflichtworkflow ist das die
bemerkenswerteste Lücke des ganzen Durchlaufs — und die mit den unmittelbarsten
Folgen, weil der schnellste Weg zu Grün darin bestünde, die neue Sicherheitsregel
im Mock wieder aufzuweichen.
---
*Alle eigenen Prüfungen read-only (grep/sed/read auf `lib/`, `test/`,
`android/`, `melo_cloud.py`). Keine git-Operationen, keine `.env`, keine
Server-Prozesse. Geschrieben wurde ausschließlich diese Datei.*
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
# YouTube-Tab: Gast-Zugang mit Tages-Limit
Status: Approved (Dustin, 2026-08-26) — bereit für Implementierungsplan.
## Kontext
Der YouTube-Downloader/die YT-Suche laufen über den Baka-Proxy
(`yt.baka-net.de``yt_proxy.py`), der für **jede** Anfrage einen gültigen
Baka-Auth-Bearer-JWT verlangt (`auth_common.verify_token`). Seit dem Fix vom
2026-08-25/26 melden sich Server-User (Dustin, Baka, Tinker) automatisch im
Hintergrund an (`BakaAuth.autoAnmelden`, siehe `lib/services/baka_auth.dart`),
Gäste sehen weiterhin den manuellen „Beim Baka-Konto anmelden“-Dialog.
Neue Anforderung (Dustin, 2026-08-26): Jeder Cloud-Account-User (die
baka-auth-User: baka, dustin, tinker — und künftig per Einladungscode
registrierte weitere User) bekommt automatisch Dustins YouTube-Cookies und
hat kein Limit. **Gäste ohne Cloud-Account sollen die App trotzdem nutzen
können, aber mit einem Tages-Limit von 5 Downloads und ohne Cookies.**
Heute ist das serverseitig unmöglich: Ohne gültigen Bearer-Token antwortet
jeder Endpunkt mit 401. Es gibt keinen Weg, einen Gast über mehrere
Anfragen hinweg wiederzuerkennen.
## Ziel
Ein dritter, anonymer Zugangsweg („Gast-Token“) neben dem bestehenden
Baka-Auth-JWT, mit serverseitig durchgesetztem Tages-Limit für Downloads.
Suche bleibt für Gäste unbegrenzt. Cookies bleiben Cloud-Accounts
vorbehalten.
## Nicht-Ziele
- Kein Ersatz für das bestehende Baka-Auth-System — beide Wege bestehen
parallel.
- Kein Schutz gegen App-Neuinstallation/Token-Löschen — ein Gast kann sich
durch Neuinstallation ein neues Kontingent holen. Das ist eine bewusste
Hürde, kein harter Schutz (siehe Entscheidung unten).
- Keine Änderung an „nur Audio“ — das gilt schon für alle User (yt-dlp
extrahiert immer MP3, es gibt keine Video-Download-Option in der App).
- Keine UI/Server-Änderung an bestehenden Cloud-Account-Flows außer der
Cookie-Beschränkung (die war vorher implizit für alle gleich, jetzt
explizit an den Zugangsweg gekoppelt).
## Entscheidungen (aus dem Brainstorming, mit Dustin abgestimmt)
| Frage | Entscheidung |
|---|---|
| Gast-Identität | Geräte-gebundener, anonymer Token — kein IP-basiertes Limit, keine Einladungscode-Pflicht für Gäste |
| UX-Fluss | Bewusste Wahl: Gast sieht „Anmelden“ UND „Als Gast fortfahren (5/Tag)“ als zwei Buttons, kein automatisches Freischalten |
| 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 |
## Architektur
Drei Zugangsklassen beim Proxy:
1. **Cloud-Account** (`Authorization: Bearer <JWT>`, wie heute) — unbegrenzt,
Cookies automatisch.
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.
## Server-seitige Komponenten
**Betrifft `/home/dustin/scripts/yt_proxy.py` und ggf. `auth_common.py`
liegt im `claude-server`-Worktree, nicht in `~/mello-dev/app`. Diese Spec
beschreibt den Vertrag, den die App-Seite braucht; die Umsetzung macht die
Server-Session.**
### Neuer Endpunkt: `POST /api/guest-token`
Kein Auth-Header nötig. Erzeugt einen neuen, zufälligen Token
(`secrets.token_hex(24)`, 48 Hex-Zeichen) und legt eine Zeile in einer
neuen SQLite-Tabelle an:
```sql
CREATE TABLE IF NOT EXISTS guest_quota (
token TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0,
count_date TEXT NOT NULL, -- 'YYYY-MM-DD', Server-Zeit
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
Antwort `200`: `{"guest_token": "<token>"}`.
Speicherort: neue Datei `/home/dustin/yt-cache/guest_quota.db` (analog zum
bestehenden `DOWNLOAD_DIR`-Cache, getrennt vom Lieder-Register und von
`baka_auth.db`, da funktional unabhängig).
### `_checke_auth()` erweitert
Bisher: `True`/`False`. Neu: gibt zurück, welche Zugriffsart vorliegt
(z. B. ein kleines Ergebnis-Objekt `{"cloud": bool, "guest_token": str|None}`)
statt nur ja/nein:
- `Authorization: Bearer <JWT>` gültig → Cloud-Zugriff (wie heute).
- Sonst, falls `X-Guest-Token` gesetzt und in `guest_quota` vorhanden →
Gast-Zugriff mit dem jeweiligen Token.
- Sonst → kein Zugriff, 401 wie heute.
### `GET /api/search`
Cloud- **und** Gast-Zugriff erlaubt, keine Zählung für Gäste.
### `POST /api/yt-dl`
- Cloud-Zugriff: wie heute, `cookies`-Parameter aus dem Body wirksam.
- Gast-Zugriff: `cookies` wird serverseitig auf `False` erzwungen
(Body-Wert ignoriert). Vor dem Download: `count_date` der Token-Zeile
mit dem heutigen Server-Datum vergleichen — weicht es ab, `count` auf 0
und `count_date` auf heute zurücksetzen (Tageswechsel). Ist danach
`count >= 5``429` mit
`{"error": "Tages-Limit erreicht (5/Tag) — ab morgen wieder verfügbar", "cooldown": <Sekunden bis Mitternacht>}`.
Sonst: Download wie bisher, bei Erfolg `count += 1` in derselben
Transaktion wie die Antwort (kein Doppelzählen bei Netzwerkfehlern nach
dem Schreiben — Erfolg zuerst prüfen, dann zählen).
- Erfolgsantwort bekommt zusätzlich `"guest_remaining": <int>` (nur bei
Gast-Zugriff; bei Cloud-Zugriff entfällt das Feld), damit die App den
Zähler ohne Extra-Anfrage aktualisieren kann.
### `GET /api/dl/<file>`
Cloud- und Gast-Zugriff erlaubt (die Datei wurde beim `yt-dl`-Aufruf schon
gezählt, hier nur Auslieferung).
### Fehlerfall: unbekannter/ungültiger Gast-Token
Antwort wie „kein Zugriff“ (401 mit `{"error": "Unauthorized"}` — nicht von
einem abgelaufenen Cloud-Token zu unterscheiden nötig, die App behandelt
beide gleich: neuen Gast-Token holen, siehe unten).
## App-seitige Komponenten (diese Seite implementiere ich)
### Neuer Service `GastZugang` (`lib/services/gast_zugang.dart`)
Analog zu `BakaAuth`, aber ohne Passwort:
```dart
class GastZugang extends ChangeNotifier {
String? _token;
int? _verbleibend; // zuletzt vom Server gemeldeter Stand, nur fürs UI
bool get hatToken => _token != null;
int? get verbleibend => _verbleibend;
Map<String, String> get gastHeader =>
{if (_token != null) 'X-Guest-Token': _token!};
Future<String?> holeToken(); // POST /api/guest-token, speichert Token sicher
void merkeVerbleibend(int n); // aus der yt-dl-Antwort übernehmen
}
```
Token-Speicherung: `flutter_secure_storage`, eigener Schlüssel
`guest_token` (gleiches Muster wie `BakaAuth`/`TokenSpeicher`).
### `YtDownloadService` / `YtSearchService`
Bekommen einen optionalen zweiten Header-Lieferanten: statt nur
`auth.authHeader` können sie auch `gast.gastHeader` verwenden, je nachdem
welcher Zugang aktiv ist. `herunterladen()` liest bei Gast-Zugriff
`daten['guest_remaining']` aus der Erfolgsantwort und ruft
`gast.merkeVerbleibend(...)`. Ein `429` wird wie die bestehenden
Fehlerfälle über `_scheitere(text)` gemeldet (Klartext aus der
Server-Antwort, kein neuer Fehlerpfad nötig).
### UI (`_YouTubeBereich` in `downloads_screen.dart`, `YoutubeSearchScreen`)
Für echte Gäste (`!_serverUser && !auth.istAngemeldet && !gast.hatToken`):
zwei Buttons nebeneinander/untereinander — „Beim Baka-Konto anmelden“
(bestehend) und „Als Gast fortfahren (5 Downloads/Tag)“ (neu, ruft
`gast.holeToken()`). Nach erfolgreichem Gast-Token: Adressfeld/Suchfeld wie
gewohnt freigeschaltet, zusätzlich ein kleiner Text „noch N von 5 heute“
(aus `gast.verbleibend`, `null` vor dem ersten Download = kein Zähler
sichtbar, erst nach dem ersten Download bekannt).
Der bestehende Cookie-Schalter (`SwitchListTile`, „YouTube-Cookies des
Servers verwenden“) wird für Gäste ausgeblendet — der Server ignoriert den
Wert ohnehin, ein sichtbarer aber wirkungsloser Schalter wäre irreführend.
## Fehlerfälle (App-Seite)
- **Gast-Token vom Server abgelehnt** (401 trotz gesetztem `X-Guest-Token`,
z. B. weil die Server-DB zurückgesetzt wurde): App holt automatisch
einen neuen Token und meldet den fehlgeschlagenen Versuch wie einen
normalen Fehler („bitte erneut versuchen“) — kein automatischer Retry
des Downloads selbst, um keine Doppel-Downloads auszulösen.
- **429 Tages-Limit erreicht**: Klartext-Fehlermeldung aus der
Server-Antwort, kein Absturz, kein automatischer Retry.
- **App-Neuinstallation**: neuer Gast-Token, neues Kontingent — bewusst
akzeptiert (siehe Nicht-Ziele).
- **Netzwerkfehler bei `POST /api/guest-token`**: wie bestehende
„Proxy nicht erreichbar“-Fehler behandelt.
## Tests
- **Server** (schreibt die `claude-server`-Session): Unit-Tests für
`guest_quota`-Zählung, Tageswechsel-Reset, Cookie-Ausschluss bei
Gast-Zugriff, 429 bei erreichtem Limit, Suche ohne Zählung.
- **App** (schreibe ich, TDD): `GastZugang` (Token holen, speichern,
`merkeVerbleibend`), Widget-Tests für die zwei Gast-Buttons und den
Zähler-Text, `YtDownloadService`/`YtSearchService` mit Gast-Header statt
Bearer-Header, 429-Fehlertext-Anzeige.
## Offene Abhängigkeit
Diese Spec beschreibt einen Vertrag zwischen App und Server. Die
App-seitige Implementierung (Service, UI) kann erst sinnvoll gegen den
echten Server getestet werden, wenn `POST /api/guest-token` und die
erweiterte `_checke_auth()`-Logik in `yt_proxy.py` existieren. Bis dahin
entwickle ich die App-Seite gegen einen gemockten Proxy (wie bei
`BakaAuth`/`YtDownloadService` schon üblich) und dokumentiere die exakten
Endpunkt-Erwartungen oben so genau, dass beide Seiten unabhängig
implementiert werden können.
@@ -0,0 +1,699 @@
# Sync-Ausbau: Favoriten-Fix, Auswahl-Upload, Einzel-Song-Offline, Playlist-Sicherung
Status: **Freigegeben, umsetzungsbereit** (2026-08-27).
Nachgeschärft nach agent-review-panel (Urteil Phase 14: Score 6/10,
Verdikt „Spec nachschärfen dann freigeben"), alle 17 Aktionspunkte
A1A17 eingearbeitet, Gegenlesen durch einen frischen Prüfer bestanden
(Bedingung des Richters erfüllt). Die einzige offene Frage (A6,
Löschpfad) ist beantwortet und serverseitig behoben — siehe §ERLEDIGT.
**Keine Stufe ist blockiert.** Implementierungsplan:
`docs/superpowers/plans/2026-08-27-sync-ausbau.md` (13 Tasks).
## Kontext
Die Melo-App synchronisiert heute schon Musikdateien UND Metadaten mit der
Melo-Cloud (`cloud.baka-net.de``melo_cloud.py`, Bearer-JWT via
`BakaAuth`): `SyncService` lädt lokale Songs ohne Cloud-ID automatisch
hoch (Multipart, 50 MB-Grenze, Server-Dedup per sha256 +
Akustik-Fingerprint), lädt neue Server-Songs herunter, zieht Tombstones
in beide Richtungen nach (mit Lösch-Bremse) und läuft automatisch bei
App-Start/Resume (15-Minuten-Drossel) mit Fortschrittsanzeige in den
Einstellungen. Der Server hardlinkt jede hochgeladene Datei aktiv nach
`/home/dustin/navidrome/music` und stößt einen Navidrome-Scan an — der
Weg Handy→Server→Navidrome-Bibliothek existiert also bereits **und er
läuft auch rückwärts**: verschwindet die lokale Datei, meldet der Sync
die Löschung, und der Server entfernt die Audiodatei aus Registry **und**
Navidrome-Bibliothek (Details unter §Risiken, „Irreversibler
Löschpfad").
`DownloadService` kann ganze Alben/Künstler in den App-Speicher offline
nehmen (Fortschritt + Abbrechen), dazu gibt es einen automatischen
Abspiel-Cache (Standard 2 GB, einstellbar 08192 MB;
`app_settings.dart:28`, `:31`).
Diese Spec schließt die verbliebenen Lücken (Auftrag Dustin, 2026-08-26,
im Brainstorming zerlegt und entschieden):
| Entscheidung | Ergebnis |
|---|---|
| Zerlegung | Erst Sync-Ausbau (diese Spec, Android), Cross-Platform Mac+Windows als **eigenes späteres Projekt** |
| Upload-UX | Bestehenden Auswahl-Modus in „Meine Musik“ um „Auf den Server laden“ erweitern; Auto-Upload beim Sync bleibt |
| Umfang | Einzel-Song-Offline + Playlist-Sync + Fortschritts-Notification/Sync-Bericht + Echtzeit-SSE; Favoriten-Merge-Fix immer dabei *(durch Review überholt — siehe unten)* |
| Playlist-Konflikte | Vereinigung (Union) wie bei Favoriten; Reihenfolge: längerer Stand gewinnt *(durch Review überholt — siehe unten)* |
| Bau-Ansatz | Ansatz 1: inkrementeller Ausbau des bestehenden `SyncService`, neue Logik in kleinen separaten Einheiten |
*Die Tabelle gibt den Stand des Brainstormings wieder — die Zeilen
„Umfang" und „Playlist-Konflikte" sind durch das Review überholt, die
Korrektur steht direkt darunter.*
**Was das Review daran geändert hat** (die Zerlegung, die Upload-UX und
der Bau-Ansatz bleiben unangetastet):
- Der **Umfang** schrumpft: SSE und die persistente Notification werden
eigene spätere Stufen (§Spätere Stufen), Playlist-Sync wird auf eine
einseitige Sicherung reduziert.
- Die **Union-Semantik bei Favoriten bleibt genau wie entschieden**
nur der Schreibweg wechselt vom Voll-Ersatz auf additive Pushes (A1).
Das ist keine Überstimmung der Auftraggeber-Entscheidung, sondern ihre
Präzisierung.
- Die **Playlist-Konfliktregel** („längerer Stand gewinnt") entfällt
ersatzlos, weil es ohne Rück-Merge keinen Konflikt mehr gibt (A5).
## ERLEDIGT — Löschpfad ist gefixt (2026-08-27)
> **Die Rückfrage an Dustin („Ist der irreversible Löschpfad gewollt?")
> ist beantwortet: nein.** Hermes hat den Fix am selben Tag in
> `melo_cloud.py` eingebaut und den Dienst neu gestartet.
>
> **Was sich geändert hat:** Im Dedup-Zweig von `upload()` prüft der
> Server jetzt `registry_pfad(sid) is None` und stellt in diesem Fall die
> Datei aus den hochgeladenen Bytes wieder her (`shutil.move` nach `REG`
> plus `UPDATE registry SET path`), statt sie über `os.remove(tmp)` zu
> verwerfen. Der bestehende `verknuepfe_navidrome`-Aufruf hängt sie
> danach automatisch zurück in die Navidrome-Bibliothek.
>
> **End-to-End verifiziert** (2026-08-27, eigene Testdatei, Fake-Nutzer,
> Testdaten danach restlos entfernt — Registry vor und nach dem Test bei
> 325 Titeln): Upload → beide Kopien da, Download bitgenau · Löschung →
> beide Dateien weg, Download 404 (Bug reproduziert) · **erneuter Upload
> → beide Dateien zurück, Download wieder bitgenau identisch.**
>
> **Folge für diese Spec: keine Stufe ist mehr blockiert.** Der
> ursprüngliche Grund, Feature 3 (Auswahl-Upload) hinter die Klärung zu
> stellen — es gibt mehr Titeln eine cloudId und vergrößert damit die
> Angriffsfläche —, ist entfallen: Ein versehentlicher Löschvorgang ist
> jetzt durch erneutes Hochladen reparabel. Die Reihenfolge in
> §Reihenfolge bleibt trotzdem so bestehen, weil sie auch aus anderen
> Gründen sinnvoll ist (Stufe 1 behebt den einzigen belegten Bug und
> hängt an nichts).
>
> **Nebenbefund aus dem Test, nicht blockierend:** `_link_user()` wird
> nur im Dedup-Zweig aufgerufen (der Neu-Zweig kehrt vorher zurück) und
> legt einen dritten Hardlink unter `users/<user>/<sid>.mp3` an, den
> `_entferne_datei_wenn_verwaist()` beim Löschen nicht mit abräumt. Bisher
> folgenlos (das Verzeichnis war leer, weil im Normalbetrieb fast alles
> über den Neu-Zweig läuft), aber jeder künftige Dedup-Upload hinterlässt
> einen verwaisten Hardlink. Server-Hygiene für Hermes, kein Datenverlust.
## Ziele
1. **Favoriten-Merge-Fix** (Datenverlust-Bug): kein Gerät überschreibt
mehr die Server-Favoriten mit seinem lokalen Stand.
2. **Gezielter Upload**: einzelne Songs im Auswahl-Modus markieren und
hochladen.
3. **Einzel-Song-Offline**: einzelne Server-Titel offline nehmen, nicht
nur ganze Alben/Künstler.
4. **Playlist-Sicherung**: lokale Playlisten überleben ein
zurückgesetztes Handy (einseitig, siehe Feature 2).
5. **Sichtbarkeit**: „Was ist neu“-Bericht als In-App-Dialog.
## Nicht-Ziele
- **Kein Cross-Platform** (Mac/Windows) — eigenes Folgeprojekt.
`windows/`/`linux/` existieren nicht, `just_audio` hat kein
Windows-Backend; das ist Plattform-Infrastruktur, keine Sync-Logik.
- **Kein Delta-Protokoll** (`/sync?since=…`): bei ~330 Songs auf dem
Server ist die Voll-Liste billig; der Delta-Endpunkt deckt zudem nur
Songs ab, nicht Favoriten/Playlisten. YAGNI.
- **Kein Hintergrund-Sync** (WorkManager o. ä.): Sync weiterhin nur bei
geöffneter App (Start/Resume/manuell) — wie bisher und wie in v2.
- **Kein Chunked/Resumable Upload**: 50-MB-Dateien am Stück wie bisher.
- **Kein Echtzeit-Sync (SSE) in dieser Stufe.** Der beworbene
Haupt-Nutzen („neuer Song erscheint in Sekunden auf dem anderen
Gerät") hängt am `song_upload`-Event, das `melo_cloud.py` heute nicht
feuert (`_emit_event` nur bei `:393`, `:410`, `:687`) und das diese
Spec ausdrücklich auslagert — am Auslieferungstag könnte SSE also
genau das nicht, wofür es gebaut wird. Die drei vorhandenen Kanäle
(`song_delete`, `song_update`, `song_favorite`) haben heute keinen
Adressaten (Live-Stand: ein Nutzer mit Songs, 0 gelöschte,
0 Favoriten, 0 Playlisten). Dem stehen acht eigene Fehlerklassen
gegenüber: eigener `http.Client` (der geteilte aus
`melo_cloud_service.dart:82-83` würde beim `close()` laufende
Sync-Requests mitreißen), Heartbeat-Watchdog gegen den halboffenen
Socket nach WLAN↔LTE, dauerhafter 401-Ausstieg (`baka_auth.dart` hat
kein Refresh), Selbst-Echo-Unterdrückung (`melo_realtime.py:56-70`
broadcastet an alle Queues des Nutzers ohne Absenderkennung),
Timer-Abbau bei `paused`, Debounce, Backoff, Nachhol-Anstoß.
**Ehrlicher Preis:** Änderungen anderer Geräte erscheinen beim
nächsten App-Start oder Zurückkehren — genau wie heute. Es gibt keinen
billigeren Poll-Ersatz: `automatisch()` läuft nur aus `initState` und
`resumed` (`main.dart:175`, `:203`), ein Timer existiert nicht.
- **Keine persistente Fortschritts-Notification in dieser Stufe** — sie
ist die einzige Quelle einer neuen Dependency und wird eigene Stufe
(§Spätere Stufen, Stufe B).
- **Kein beidseitiger Playlist-Merge** — eigene, spätere Spec (A5).
- **Keine Playlist-Tombstones auf dem Server**: ein
Server-Tombstone-System wäre ein eigener Auftrag.
- **Kein Rollback auf eine ältere App-Version** nach der
Schema-Migration: `database.dart:154-192` kennt nur `onCreate` und
`onUpgrade`.
## Spätere Stufen (bewusst nach hinten geschoben, nicht verworfen)
| Stufe | Inhalt | Vorbedingung |
|---|---|---|
| Notification Stufe B | persistente Fortschritts-Notification via `flutter_local_notifications` | grüner Beweis-Build (siehe unten) |
| SSE | Echtzeit-Sync, eigene Spec | `song_upload`-Event steht in `melo_cloud.py` |
| Playlist-Merge | beidseitiger Abgleich, eigene Spec | Rename-Endpunkt + Tombstones serverseitig |
| Basis-Snapshot | verlässliche Lösch-Propagation bei Favoriten | erst wenn die additive Semantik in der Praxis stört |
**Notification Stufe B, Details (eigenes Arbeitspaket):** Sie erzwingt
`flutter_local_notifications` und damit
`isCoreLibraryDesugaringEnabled = true` plus
`coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")` in
`android/app/build.gradle.kts` — die Datei hat heute überhaupt keinen
`dependencies { }`-Block, der Gradle-Heap steht auf 2 GB
(`gradle.properties:1`, dokumentierte OOM-Quelle), AGP ist 9.0.1
(`settings.gradle.kts:22`) und android-37 ist ein Nachbau von 36.
**Erstes Abnahmekriterium der Stufe B ist ein grüner
`flutter build apk --target-platform android-arm64` nach dem `pub add`
vor jeder Zeile Notification-Logik.** `multiDexEnabled` entfällt aus der
Forderung: bei `minSdkVersion = 24` (`FlutterExtension.kt:26`) ist es
gegenstandslos. Mitzuentscheiden: Channel mit `Importance.low`,
`onlyAlertOnce: true`, und ein `cancel()` beim Init gegen die nach
App-Kill stehengebliebene Fortschritts-Notification.
**SSE später:** Die acht Härtungs-Bausteine gehören dann in **jene**
Spec, nicht in einen Plan zu dieser. Vorbedingung außerdem: der additive
Delta-Push (Feature 1) feuert je gepushtem Favoriten ein
`song_favorite`-Event (`melo_cloud.py:687`) — beim ersten Lauf nach der
Umstellung also so viele Events wie lokale Favoriten. Ohne SSE ist das
folgenlos; kommt SSE, muss die Selbst-Echo-Unterdrückung **vorher**
stehen.
## Architektur
Rückgrat bleibt `SyncService` (`lib/services/sync_service.dart`) +
`MeloCloudService` (`lib/services/melo_cloud_service.dart`). Neu:
| Einheit | Datei | Verantwortung |
|---|---|---|
| Merge-Logik | `lib/services/sync_merge.dart` (neu) | Reine Funktionen ohne I/O: `fehlendeFavoriten` (Mengendifferenz beider Richtungen), `berichtFaellig`. Voll unit-testbar. |
| Cloud-Erweiterung | `melo_cloud_service.dart` (erweitert) | `setzeFavorit(cloudId, set)` (neu, deterministisch), Playlisten-CRUD; `parseFavoriten` gehärtet. |
| Sync-Erweiterung | `sync_service.dart` (erweitert) | Neue Phase (additiver Favoriten-Abgleich), `ladeAusgewaehlteHoch`, `abbrechen()`, Phasen-Isolation, Erfolgs-Flag. |
| UI | bestehende Screens | Auswahl-Modus-Aktion, Einzel-Song-Offline-Knopf, Bericht-Dialog. |
**Neue Abhängigkeit: keine.** Das war in der Vorfassung
`flutter_local_notifications` und damit der einzige harte Build-Blocker
der ganzen Planung; er wandert mit Stufe B nach hinten.
Ebenfalls entfallen gegenüber der Vorfassung: `echtzeit_sync.dart` (A7)
und `sync_benachrichtigung.dart` (A8). Die einzige verbliebene reine
Entscheidungsfunktion des Berichts (`berichtFaellig`) wohnt in
`sync_merge.dart` — eine eigene Datei für eine Funktion wäre Abstraktion
ohne zweiten Aufrufer.
## Feature-Details
### 1. Favoriten-Abgleich (Datenverlust-Fix)
Heutiger Bug: `_gleicheFavoritenAb()` (sync_service.dart) POSTet die
lokale Favoritenliste als Komplett-Ersatz — ein frisch installiertes
Gerät löscht damit beim ersten Sync alle Server-Favoriten.
**Geltungsbereich:** Der Favoriten-Abgleich betrifft ausschließlich die
lokale `Favorites`-Tabelle. Der Stern in der Server-Titel-Ansicht
(`ServerFavoriteButton`, `lib/shared/server_favorite_button.dart:42`,
benutzt in `now_playing_screen.dart:279`) ist ein **anderes** System —
Navidrome-Starring über `navidrome.setFavorite(navidromeId)` — und wird
von dieser Spec nicht angefasst. Beide erscheinen im Now-Playing-Screen
als dasselbe Herz-Symbol; dass sie getrennt bleiben, ist eine
Entscheidung, keine Auslassung.
`PlaylistService.syncFavoritesFromServer()`
(`playlist_service.dart:52-71`) ist ein dritter Kanal und heute
wirkungslos (`db.songExists(navidromeId)` gegen lokale UUIDs) — er wird
**nicht** Teil des Abgleichs.
Neu, zwei Mechanismen:
- **Sofort-Push beim Antippen:** `PlaylistService.toggleFavorite` schickt
online zusätzlich fire-and-forget ein deterministisches
`set:true/false` an den Server (`POST /favorites/toggle`).
Deterministisch statt Toggle, damit ein abweichender Server-Zustand den
Wunsch nie invertiert. Nur für Songs mit cloudId; Fehler werden still
geschluckt (der nächste Voll-Sync korrigiert additiv).
- **Additiver Abgleich beim Sync**, neue Phase in `synchronisiere()`:
> Die Sync-Phase benutzt **nie** `POST /favorites` (Voll-Ersatz). Sie
> schreibt ausschließlich additiv:
> 1. `GET /favorites` → `server` (Menge von cloudIds).
> 2. Für jede cloudId in `lokal \ server`: `POST /favorites/toggle` mit
> `{"song_id": …, "set": true}` (existiert und ist deterministisch:
> `melo_cloud.py:1298-1302` → `handle_favorites_toggle:644-688`; die
> Client-Methode ist neu).
> 3. Für jede cloudId in `server \ lokal`: lokal Favorit setzen, sofern
> der Song lokal auflösbar ist.
> 4. **In keiner Richtung wird etwas entfernt.**
>
> `MeloCloudService.setzeFavoriten` (`melo_cloud_service.dart:268-280`)
> wird nicht mehr benutzt und entfällt.
>
> **Wirkung:** Der Datenverlust-Bug aus Ziel 1 ist danach **strukturell**
> unmöglich, nicht nur durch eine Regel verhindert — es existiert kein
> Codepfad mehr, der den Server-Stand ersetzen kann. Deckel: höchstens
> 200 Pushes je Lauf, der Rest im nächsten Lauf (die Pushes sind
> idempotent, ein Teilausfall heilt sich beim nächsten Lauf selbst).
**GET-Härtung (`parseFavoriten`):**
> Ein GET gilt nur als erfolgreich, wenn HTTP 200 **und** kein
> `error`-Schlüssel **und** `favorites` als Liste vorhanden ist. Ein
> fehlender `favorites`-Schlüssel ist ein **Fehler, keine leere Menge**;
> `parseFavoriten` wirft dann `CloudException`, genau wie `parseListe`
> (`melo_cloud_service.dart:99-100`) und `parseUpload` (`:111-112`) es
> bereits tun. Hintergrund: der Router verdrahtet für `GET /favorites`
> hart 200 (`melo_cloud.py:1292-1293`), und sechs Handler desselben
> Servers geben Fehler im 200er-Körper zurück (`:418, 491, 545, 766,
> 1021, 1118`).
>
> **Bestandsänderung, Teil des Arbeitspakets:**
> `test/services/melo_cloud_service_test.dart:98-101` prüft heute
> ausdrücklich das Gegenteil
> (`expect(parseFavoriten(jsonEncode({'status':'ok'})), isEmpty)`) und
> wird mit umgeschrieben. Das ist kein Regressionsfehler.
Schlägt das GET fehl, wird die Phase übersprungen. Der Unterschied zur
Vorfassung: das ist jetzt eine *Optimierung* (nichts zu tun ohne
Server-Stand), keine *Sicherheitsregel* mehr — ein fälschlich leeres GET
würde im schlimmsten Fall die lokalen Favoriten additiv hochpushen, also
harmlos und idempotent.
**Pull-Richtung, unauflösbare cloudIds:**
> Server-Favoriten, deren cloudId sich lokal auf keinen Song abbilden
> lässt (Download fehlgeschlagen, Titel noch nicht geladen), werden
> **übersprungen, nicht gelöscht**: `Favorites.songId` verweist auf
> `Songs.id` (`database.dart:102-107`); ein Favorit ohne Song wäre über
> `watchFavorites()` (innerJoin, `:371-375`) unsichtbar, würde aber über
> `favoriteSongIds()` (`:534-536`) weiter mitgeschleppt und wieder
> hochgepusht. Der additive Abgleich schreibt sie deshalb weder lokal,
> noch entfernt er sie serverseitig.
> Hinweis am selben Ort: `favoriteSongIds()` liefert auch Favoriten
> **getombsteter** Songs — es ist ein schlichtes `select(favorites).get()`
> (`:534-537`) ohne Join auf `Songs.deleted`, und `markMissing`
> (`:237-245`) setzt nur `Songs.deleted`, die Favorites-Zeile überlebt.
> Unter dem additiven Abgleich harmlos, unter jedem Voll-Ersatz nicht.
- Songs ohne cloudId: bleiben lokal-only, tauchen in keiner Richtung im
Abgleich auf.
**Bekannte Einschränkung (bewusst, dokumentiert):**
> Der Abgleich ist rein additiv. Ein Ent-Favorisieren wirkt sofort lokal
> und — online — auch auf dem Server, ist aber **nicht
> geräteübergreifend garantiert**: Hält ein zweites Gerät den Favoriten
> noch, bringt dessen nächster Sync ihn auf allen Geräten zurück. Das
> gilt für offline **und für online** entfernte Herzen. Bewusster Tausch,
> wie in v2: kein Datenverlust ist wichtiger als verlässliche
> Lösch-Propagation.
> Falls das in der Praxis stört: Folgeschritt „Basis-Snapshot" (Backlog).
> Er lässt sich später **ohne Umbau** ergänzen, weil der Schreibweg dann
> schon der Delta-Push ist — aus `set:true` wird zusätzlich `set:false`.
*(Die Vorfassung behauptete an dieser Stelle, Ent-Favorisierungen
propagierten über den Sofort-Push und nur offline entfernte Herzen kämen
zurück. Das war falsch und ist ersatzlos gestrichen.)*
### 2. Playlist-Sicherung (Stufe 1, einseitig)
> **Playlist-Sync Stufe 1 = einseitige Sicherung.** Die App meldet lokale
> Playlisten-Änderungen (anlegen / Song hinzufügen / Song entfernen /
> Reihenfolge) online fire-and-forget an den Server und persistiert die
> vom Server vergebene cloudId (Drift-Migration
> `Playlists.cloudId TEXT NULL` bleibt).
> Es findet **kein Rück-Merge** statt: Server-Playlisten werden nur dann
> lokal angelegt, wenn die lokale Playlisten-Tabelle **leer** ist
> (Neuinstallation / Wiederherstellung).
>
> Damit entfallen ersatzlos: Namenszuordnung, Reihenfolge-Schiedsrichter
> („längerer Stand gewinnt"), Gleichstands-Regel, Tombstone-Semantik und
> die Abhängigkeit vom fehlenden Rename-Endpunkt. Die Identität stammt
> **immer** aus dem eigenen `POST /playlists` und ist stabil
> (`user_playlists.id` ist `INTEGER PRIMARY KEY AUTOINCREMENT`).
>
> **Nicht abgedeckt (dokumentiert):** Umbenennungen propagieren nicht.
> Playlisten-Änderungen auf einem zweiten Gerät erscheinen auf dem ersten
> nicht.
> Der beidseitige Playlist-Merge ist eine eigene, spätere Spec.
>
> **Vorbedingung (Server-Auftrag, blockierend):** `melo_cloud.py:505`
> löscht `user_playlist_songs` für jede `playlist_id` **ohne
> `user`-Bedingung** — echte Fremddaten-Löschung. Muss geflickt sein,
> bevor die App diesen Endpunkt regelmäßig benutzt.
Endpunkte: `GET/POST/DELETE /playlists`, `GET /<id>`,
`POST /<id>/songs`, `DELETE /<id>/songs/<sid>`, `PUT /<id>/positions`
(`melo_cloud.py:1251-1284`). **Einen Rename-Endpunkt gibt es nicht**
kein `PUT`/`PATCH` auf die Playlist selbst; `handle_rename:749` betrifft
Songs. (Die Vorfassung behauptete das Gegenteil.)
Songs ohne cloudId in einer Playlist bleiben lokal in der Playlist und
werden zum Server einfach nicht mitgemeldet — kein Fehler.
*Ebenfalls vertretbar und heute kostenlos (0 Playlisten auf dem Server):
Feature 2 ganz herausschneiden. Entscheidung liegt bei Dustin; das
Weiterlaufen im Zustand der Vorfassung (vier offene
Semantik-Entscheidungen) ist es nicht.*
### 3. Upload-Auswahl („Auf den Server laden“)
- Auswahl-Modus in „Meine Musik“ bekommt die Aktion **„Auf den Server
laden“**. Er existiert (`my_music_screen.dart:120`
`SortableSongList`, `lib/shared/sortable_song_list.dart:46-54`,
`:194-196`) — zu beachten ist nur das Scoping: `SortableSongList` wird
in fünf Ansichten benutzt, die Aktion würde sonst überall erscheinen.
- Neue Methode `SyncService.ladeAusgewaehlteHoch(List<Song> songs)`:
nutzt den bestehenden `_ladeHoch`-Pfad pro Song, mit Fortschritt über
die bestehenden Felder (`laeuft/erledigt/gesamt/status`).
- Songs, die schon eine cloudId haben, werden übersprungen; zu große
Dateien (>50 MB) einzeln als Fehler vermerkt, der Rest läuft weiter.
Ergebnis-Meldung im Stil „3 hochgeladen, 2 waren schon da, 1 zu groß“.
- **Abbrechen:** Der Auswahl-Upload bekommt `SyncService.abbrechen()`
nach dem Muster von `DownloadService` (`:46`, `:66-68`, `:96`) — ein
Flag, das die Schleife zwischen zwei Songs prüft. `SyncService` hat
heute **keinerlei** Abbruchmöglichkeit (0 Treffer für `abbrech|cancel`
in 376 Zeilen); ohne das ist ein versehentlich markierter
60-Titel-Upload nur durch App-Kill zu stoppen.
- **Zeitstempel:** `ladeAusgewaehlteHoch` schreibt `_letzterLauf`
(`:215-217`) **nicht** — es ist kein Sync. Sonst unterdrückt ein
manueller Upload 15 Minuten Auto-Sync (`sollAutoSync`, `:120-121`) und
verschiebt die 24-h-Uhr des Berichts; wer die App täglich zum
Hochladen öffnet, sähe den Bericht nie.
- **Offline-Modus:** Der Schalter (`lib/services/offline_mode.dart`,
`settings_screen.dart:218-231`) wirkt heute nur auf die Wiedergabe
(`main.dart:106-110`). Entscheidung: der Auswahl-Upload **respektiert
ihn nicht**, weil er eine ausdrückliche Nutzeraktion ist.
- Läuft bereits ein Sync, wird die Aktion abgewiesen („Sync läuft
gerade“) — der bestehende Doppel-Lauf-Schutz des SyncService gilt.
- Der automatische Voll-Upload beim Sync (alles ohne cloudId) bleibt
unverändert bestehen.
### 4. Einzel-Song-Offline
- `DownloadService` bekommt `ladeEinzelnenTitel(SubsonicSong song)`:
> `ladeEinzelnenTitel(song)` ruft den bestehenden **öffentlichen**
> `DownloadService.lade([song])` (`download_service.dart:72-111`) auf —
> der bringt Doppel-Lauf-Schutz (`:73`), Verbindungsprüfung (`:77-84`)
> und Fortschritts-Buchführung (`:85-104`) mit. **Nicht** `_ladeEinen`
> (`:113-139`) direkt verdrahten.
Gleiche Ablage (App-Speicher,
Application-Support/melo_downloads), gleiche Buchführung
(Downloads-Tabelle per navidromeId), gleiche Fehlerbehandlung.
- **Doppelspeicherung:** Vor dem Netz-Download prüft
`ladeEinzelnenTitel`, ob die Datei bereits im Abspiel-Cache liegt —
sonst entsteht genau die Doppelspeicherung, die
`audio_handler.dart:326-331` in der Gegenrichtung ausdrücklich
verhindert („doppelter Platz und doppeltes Datenvolumen"). Falls zu
teuer: als bekannte Einschränkung dokumentieren, nicht schweigen.
- **Kein Platz-Check, keine Schwelle:** Die 30er-Rückfrage
(`download_service.dart:13`, `:15`, `:17-21`, Aufrufer nur
`downloads_screen.dart:460` und `:496`) ist bei einem Einzeltitel per
Konstruktion wirkungslos, und einen Check auf freien Speicher gibt es
in der ganzen App nicht. Bewusst akzeptiert: der Knopf lädt genau
einen Titel.
- **Offline-Modus:** wird ebenfalls nicht respektiert (ausdrückliche
Nutzeraktion, siehe Feature 3).
- UI: Songzeilen in der Server-Album-/Künstler-Ansicht
(`server_titel_screen.dart`) bekommen den Lade-Knopf, den es heute nur
pro Album gibt, inklusive Zustand „schon offline“ mit Entfernen-Option
(bestehendes `DownloadService.entferne`). **`_LadeKnopf` ist eine
private Klasse in `lib/downloads/downloads_screen.dart:419-429`, kein
wiederverwendbares Widget** — als Muster kopieren oder vorher
extrahieren.
### 5. Sync-Bericht („Was ist neu“)
- Rein in-App, **keine neue Dependency**: Ist der letzte erfolgreiche
Sync >24 h her, sammelt der nächste Sync Zähler (neue Songs,
gelöschte, Favoriten geändert, Playlisten geändert) und zeigt danach
einmalig einen Dialog (v2-Parität „Willkommen zurück!“). Stand in
SharedPreferences.
- **Erstfall:** `letzterLauf == null` (Neuinstallation, nach Abmelden,
nach App-Daten-Löschen) gilt **nicht** als fällig — sonst begrüßt der
Bericht ein frisch installiertes Gerät mit „Willkommen zurück! 325
neue Songs". Achtung: `sollAutoSync` (`sync_service.dart:120-121`)
behandelt `null` genau umgekehrt und ist hier **kein** Vorbild.
- **„Erfolgreich" heißt:** alle Phasen ohne geschluckten Fehler. Dafür
führt `synchronisiere()` ein Flag, das jede Phase bei einem Fehlschlag
löscht; der Bericht-Zeitstempel wird nur bei gesetztem Flag
geschrieben. Heute schreibt `:215-217` denselben Zeitstempel, egal ob
Phasen ausgefallen sind (`_meldeLoeschungen:243-252` schluckt Fehler).
- Die Entscheidung „Bericht fällig?“ liegt als reine Funktion
(`berichtFaellig`) in `sync_merge.dart` und ist ohne Plattform-Kanäle
testbar.
- Die In-App-Fortschrittsanzeige in den Einstellungen bleibt wie heute.
## Sync-Phasen nach Ausbau (Reihenfolge im Lauf)
1. Tombstones nachziehen (bestehend)
2. Neue Server-Songs herunterladen (bestehend)
3. Lokale Songs ohne cloudId hochladen (bestehend)
4. **Additiver Favoriten-Abgleich (neu)**
5. Verlauf melden (bestehend)
6. Zeitstempel + ggf. Bericht (erweitert)
Der Zeitstempel wird **vor** dem Listen genommen und **nach** allen
Phasen geschrieben (heute: `_letzterLauf = DateTime.now()` in `:215`,
nach allen Phasen — der Snapshot-vor-dem-Listen fehlt noch).
Die Playlist-Sicherung (Feature 2) ist **keine Sync-Phase**: sie läuft
als Sofort-Push bei der Änderung, und die Wiederherstellung greift nur
bei leerer lokaler Tabelle.
## Reihenfolge (Auslieferung)
> 1. **Favoriten-Fix allein** (additiver Delta-Push, `parseFavoriten`,
> Testanpassung). Wartet auf **nichts**: kein Server-Auftrag, keine
> neue Dependency, keine Gradle-Änderung, kein neues UI-Muster.
> Einzige Stufe mit belegtem Datenverlust-Bezug.
> 2. Auswahl-Upload + Einzel-Song-Offline (UI, keine neue Dependency).
> 3. Sync-Bericht (In-App-Dialog, kein Plugin).
> 4. Playlist-Sicherung Stufe 1 — **nach** dem Server-Fix
> `melo_cloud.py:505`.
>
> *Ab hier nicht mehr Inhalt dieser Spec — beides steht unter
> §Nicht-Ziele bzw. §Spätere Stufen und ist nur der Vollständigkeit
> halber einsortiert:*
>
> 5. Notification Stufe B — beginnt mit dem grünen Beweis-Build.
> 6. SSE — eigene Spec, nach dem `song_upload`-Event.
**Warum genau diese Reihenfolge — Begründung, die mitgebaut werden
muss:**
- Stufe 1 (A1/A2/A3) **berührt den Löschpfad nicht** und hängt an keiner
offenen Frage. Sie behebt außerdem als einzige einen belegten
Datenverlust-Bug — sie zuerst zu liefern ist auch inhaltlich richtig.
- Der **Auswahl-Upload (Feature 3)** stand ursprünglich hinter der
A6-Klärung, weil er mehr Titeln eine cloudId gibt und damit die
Angriffsfläche des Löschpfads vergrößert. **Dieser Grund ist seit dem
2026-08-27 entfallen** (Löschpfad serverseitig entschärft, siehe
§ERLEDIGT). Die Stufe bleibt an ihrer Position, weil Stufe 1 den
einzigen belegten Bug behebt und deshalb vorne gehört — nicht mehr,
weil Stufe 2 blockiert wäre.
- Alles, was auf einen fremden Auftrag wartet (`song_upload`-Event) oder
auf einen Build-Umbau (`flutter_local_notifications`), steht hinten —
das betrifft die **Stufen 46**. Innerhalb dieser drei blockiert keine
Stufe eine frühere.
**Stand 2026-08-27: keine Stufe dieser Spec ist mehr blockiert.** Die
einzige offene Fremdabhängigkeit (`song_upload`-Event) betrifft
ausschließlich die spätere SSE-Stufe, die nicht Teil dieser Spec ist.
## Fehlerfälle — zu bauende Arbeit, keine Zusagen
Fünf in der Vorfassung als „bestehend“ beschriebene Schutzmechanismen
**existieren nicht** und sind Teil dieser Arbeit:
> - **Phasen-Isolation:** `synchronisiere()` hat heute **ein einziges**
> try/catch um alle Phasen (`sync_service.dart:188-228`). Jede neue
> Phase bekommt ihr eigenes try/catch mit definiertem Teil-Erfolg.
> - **„verzögert" gibt es nicht:** `if (_laeuft) return;` (`:175`)
> verwirft den Anstoß wortlos. Wer einen Nachlauf will, braucht ein
> `_nachziehen`-Flag, das im `finally` genau **einen** weiteren Lauf
> auslöst.
> - **Der Upload überlebt keinen Timeout:** `_ladeHoch` fängt nur
> `CloudException` (`:313`); der 120-s-Timeout
> (`melo_cloud_service.dart:180`) wirft `TimeoutException` und reißt
> den ganzen Lauf ab. Timeouts sind wie Einzelfehler zu behandeln.
> - **Die Drossel sitzt in `automatisch()`** (`:166-170`), nicht in
> `synchronisiere()` (`:174-180`). Ein Parameter `erzwinge` wäre
> deshalb wirkungslos und entfällt ersatzlos (er stammte aus dem
> gestrichenen SSE-Teil).
> - **Der Löschweg zum Server** wird in den Fehlerfällen nirgends
> beschrieben — siehe §Risiken, „Irreversibler Löschpfad".
Verhalten im Einzelnen:
- Favoriten-GET scheitert → Phase 4 überspringen, Sync läuft weiter
(nichts zu tun ohne Server-Stand; ein blindes additives Pushen wäre
harmlos, aber nutzlos).
- Playlist-Endpunkt scheitert → Sofort-Push still verwerfen, nächste
Änderung versucht es erneut.
- Auswahl-Upload: Datei >50 MB, Einzel-Upload-Fehler oder Timeout → im
Ergebnis vermerken, mit nächstem Song fortfahren; `abbrechen()`
stoppt zwischen zwei Songs.
- Einzel-Song-Offline: wie bestehender Album-Pfad (Fehler pro Titel,
kein Abbruch des Rests).
- Sync bereits aktiv → Auswahl-Upload wird abgewiesen (bestehender
Schutz, `:175`).
## Risiken
- Die Lösch-Bremse bremst nur Server-Löschungen; lokale Tombstones
laufen ungebremst — beim Ausbau nicht verschlimmern.
- Uploads/Downloads liegen serverseitig komplett im RAM — der
Auswahl-Upload bleibt sequenziell (kein Parallel-Upload), um den
Server nicht aufzublähen.
- v2-Lektionen übernehmen: Sync-Zeitstempel = Snapshot VOR dem Listen
(Tombstone-Race), neue Server-Songs sofort MIT cloudId in die DB
(Doppel-Download-Falle), Dateinamen-Kollisionszähler.
- Die Sicherheit der Favoriten liegt **nicht mehr in einer Regel**,
sondern darin, dass kein Voll-Ersatz-Aufruf mehr existiert.
- Der Navidrome-Playlist-Import ist nicht idempotent („🌐“-Duplikate) —
Bestandsfehler, von Feature 2 nicht angefasst, aber beim Testen
präsent.
### Löschpfad (war Backlog-P0 — am 2026-08-27 serverseitig entschärft)
**Der folgende Abschnitt beschreibt den Stand VOR dem Fix.** Die Kette
selbst besteht unverändert — eine verschwundene lokale Datei führt
weiterhin zur Server-Löschung. Was sich geändert hat: Sie ist nicht mehr
*irreversibel*. Erneutes Hochladen derselben Datei stellt sie jetzt in
Registry und Navidrome-Bibliothek wieder her (siehe §ERLEDIGT ganz oben).
Der letzte Satz des Kastens („Erneutes Hochladen repariert es **nicht**")
gilt daher **nicht mehr**.
> `markMissing` (`database.dart:237-245`) tombstoned jeden Titel, dessen
> Datei der Scan nicht findet — auch unbeabsichtigt (SD-Karte nicht
> eingehängt, Berechtigung entzogen, Dateimanager). `planeSync`
> (`sync_service.dart:68-76`) macht daraus eine Server-Löschung; die
> Lösch-Bremse (`:112-113`) greift bei 325 Titeln erst ab 109 — **1 bis
> 108 Löschungen laufen ungebremst**. Der Server löscht `registry/<sid>`
> und `navidrome/music/<sid>` (`melo_cloud.py:356-372`, `:190-202`); für
> die Datei gibt es keinen Grabstein. Erneutes Hochladen repariert es
> **nicht**: der überlebende `registry.sha256` erzwingt den Dedup-Zweig
> (`:265-267`), `shutil.move` steht nur im Neu-Zweig (`:289`),
> `os.remove(tmp)` verwirft die Bytes (`:312`), `/download` antwortet
> dauerhaft 404 (`:1400-1408`). Bisher nie ausgelöst (Live-DB:
> `SUM(deleted)=0`).
Die zugehörige Rückfrage an Dustin ist **beantwortet und behoben**
siehe §ERLEDIGT ganz oben. Was bleibt: Die App tombstoned weiterhin
großzügig (nicht eingehängte SD-Karte genügt), und die Lösch-Bremse
greift weiterhin erst ab 109 von 325 Titeln. Beides ist App-Seite,
bewusst unangetastet und kein Blocker — der Schaden ist jetzt reparabel.
Ob die App zusätzlich vorsichtiger tombstonen soll, bleibt eine offene
Backlog-Frage.
### Verworfene Alternativen
> **Verworfen: Drei-Wege-Merge gegen einen persistierten Basis-Snapshot**
> (`neu = (lokal server) (Basis \ lokal) (Basis \ server)`). Er
> löst zwar die Lösch-Propagation, führt aber den **einzigen neuen
> Datenverlustpfad** der ganzen Planung ein: Liefert `GET /favorites`
> fälschlich eine leere Liste, wird `Basis \ server = Basis`, die Formel
> kollabiert zu `lokal Basis` und löscht den gesamten bestätigten
> Bestand — lokal **und** über den Delta-Push auf dem Server und damit
> auf allen Geräten. Zusätzlich braucht er kontogebundenen Zustand mit
> Aufräumpflicht beim Abmelden; `BakaAuth.abmelden`
> (`baka_auth.dart:114-120`) räumt heute nichts ab und es gibt kein
> Token-Refresh. Für drei Nutzer, 0 Favoriten und 0 Playlisten auf dem
> Server ist der additive Abgleich der bessere Tausch. Der
> Basis-Snapshot bleibt als Folgeschritt im Backlog.
*(Diese Alternative war die Haupt-Empfehlung des Review-Panels. Sie wird
mit obiger Begründung abgelehnt — die separate Faktenprüfung hat
nachgerechnet, dass ein fälschlich leeres GET, das `parseFavoriten` heute
erzeugt, sie zur geräteübergreifenden Massenlöschung macht.)*
Ebenfalls verworfen: der beidseitige Playlist-Merge in dieser Stufe
(vier offene Semantik-Entscheidungen, siehe A5) und SSE in dieser Stufe
(eigene Vorbedingung nicht erfüllt, siehe §Nicht-Ziele).
## Tests
**Neu:**
- `sync_merge.dart`: Unit-Tests für die Mengendifferenz beider
Richtungen (leer×leer, einseitig, disjunkt, Songs ohne cloudId,
unauflösbare Server-cloudId → übersprungen) und für `berichtFaellig`
(`null` → nicht fällig, <24 h → nicht fällig, >24 h → fällig).
Deterministisch, ohne I/O.
- `sync_service.dart`: neue Phase mit MockClient. **Zwei Pflichttests:**
„200 mit Fehlerkörper → Phase übersprungen, kein Push" und „200 mit
`favorites: []` → Phase läuft normal durch" (der zweite verhindert,
dass der erste durch ein zu scharfes „wirf bei leer" trivial erfüllt
wird). Dazu: Auswahl-Upload mit Mischung aus ok/zu groß/schon da,
`abbrechen()` stoppt zwischen zwei Songs, `ladeAusgewaehlteHoch`
schreibt `_letzterLauf` nicht.
- **`PlaylistService`** — der Sofort-Push ist der einzige Online-Kanal
der Spec und hatte in der Vorfassung keinen einzigen Testeintrag:
`toggleFavorite` schickt deterministisches `set:true/false`, nur bei
cloudId, Fehler werden geschluckt.
- **`PlaylistService` — Feature 2 (einseitige Sicherung):** Sofort-Push
je Änderungsart (Playlist anlegen → cloudId wird persistiert; Song
hinzufügen/entfernen; Reihenfolge via `PUT /<id>/positions`), Songs
ohne cloudId werden nicht mitgemeldet, Endpunkt-Fehler werden still
verworfen (kein lokaler Rollback). **Pflichttest für die Kernregel:**
Server-Playlisten werden nur angelegt, wenn die lokale
Playlisten-Tabelle leer ist — Gegentest mit *einer* lokalen Playlist
legt **nichts** an (kein Rück-Merge).
- Widget-Tests: Auswahl-Modus-Aktion sichtbar + ruft Upload auf (und
erscheint **nicht** in den vier anderen `SortableSongList`-Ansichten);
Einzel-Song-Knopf lädt/entfernt; Bericht-Dialog erscheint nach
>24h-Marke und nicht bei `letzterLauf == null`.
- Drift-Migration: Test, dass Bestandsdaten die neue Spalte überleben.
**Anzupassender Bestand** (die Vorfassung nannte ausschließlich neue
Tests):
> - `test/services/melo_cloud_service_test.dart:98-101` — zementiert das
> `[]`-Verhalten, wird mit der GET-Härtung umgeschrieben.
> - `test/services/sync_service_test.dart:166-202` („Favoriten werden mit
> ihren Server-IDs gemeldet") prüft heute genau das
> Full-Replace-Verhalten, das Ziel 1 beseitigt. Sein MockClient
> beantwortet **jeden** Pfad außer `/list` mit `{'status':'ok'}`
> (`:196`) — unter der neuen Regel liefert `GET /favorites` dort 200
> ohne `favorites`-Schlüssel und die Phase wird übersprungen. Der Test
> wird auf den additiven Delta-Push umgeschrieben und bekommt eine
> **explizite** `/favorites`-Antwort.
> **Der Catch-All-Mock darf nicht aufgeweicht werden, um die neue
> Sicherheitsregel zu umgehen** — das ist der schnellste Weg zu Grün
> und der falsche.
> - Dasselbe gilt abgeschwächt für die übrigen sieben Tests derselben
> Datei.
## Offene Abhängigkeiten (geschlossene Liste)
| # | Auftrag | Art | Blockiert |
|---|---|---|---|
| 1 | `melo_cloud.py:505` — Owner-Prüfung in `handle_playlist_delete` | **Voraussetzung** | Feature 2 |
| 2 | ~~Datei-Wiederherstellung im Dedup-Zweig von `upload()`~~ | ✅ **ERLEDIGT 2026-08-27** (Hermes; end-to-end verifiziert) | nichts mehr |
| 3 | `song_upload`-SSE-Event | Voraussetzung für die spätere SSE-Stufe | nichts in dieser Spec |
| 4 | Playlist-Rename-Endpunkt (`PUT`/`PATCH`) | optional | nichts (Feature 2 braucht ihn nicht mehr) |
| 5 | Playlist-Tombstones serverseitig | optional / Backlog | nichts |
| 6 | Schema-Divergenz `ist_korrupt` / `uq_user_song` (nur in der Live-DB, nicht im `CREATE TABLE` des Skripts) | optional / Backlog | nichts — trifft nur wiederhergestellte oder Test-Instanzen |
## Backlog-Notizen
- `handle_favorites_get` liefert bereits `favorited_at`
(`melo_cloud.py:620`), lokal existiert `Favorites.createdAtMs`
(`database.dart:103`) — die Zutat für einen späteren echten Merge ist
beidseitig vorhanden, ohne dass der Server geändert werden müsste.
- `server_titel_screen._geladen` ist ein Einmal-Snapshot
(Bestandsfehler) — fällt beim Einbau des Einzel-Song-Knopfs auf.
- **Server-Hygiene (2026-08-27 beim Löschpfad-Test gefunden):**
`_link_user()` wird nur im Dedup-Zweig von `upload()` aufgerufen (der
Neu-Zweig kehrt vorher zurück) und legt einen dritten Hardlink unter
`users/<user>/<sid>.mp3` an, den `_entferne_datei_wenn_verwaist()` beim
Löschen nicht mit abräumt. Bisher folgenlos — das Verzeichnis war leer,
weil im Normalbetrieb fast alles über den Neu-Zweig läuft. Seit dem
Wiederherstellungs-Fix hinterlässt aber jeder Dedup-Upload einen
verwaisten Hardlink. Auftrag an Hermes, kein Datenverlust, eilt nicht.
- **App-Seite, bewusst offen:** `markMissing` tombstoned großzügig (eine
nicht eingehängte SD-Karte genügt) und die Lösch-Bremse greift erst ab
109 von 325 Titeln. Seit dem Server-Fix ist der Schaden reparabel, also
kein Blocker — ob die App trotzdem vorsichtiger tombstonen soll, ist
eine eigene Entscheidung.
+175
View File
@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../library/database.dart';
import '../services/download_service.dart';
import '../shared/theme.dart';
/// Übersicht der bewusst heruntergeladenen Titel, mit der Möglichkeit, sie
/// einzeln oder alle wieder vom Gerät zu nehmen.
///
/// Die Liste kommt aus der Datenbank, nicht aus dem Ordner: die Dateien
/// heißen nach einem Hash und würden hier sonst als „a3f9…mp3" stehen.
class DownloadsListeScreen extends StatelessWidget {
const DownloadsListeScreen({super.key});
static String formatiereBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
Future<void> _alleEntfernen(BuildContext context, int anzahl) async {
final dienst = context.read<DownloadService>();
final messenger = ScaffoldMessenger.of(context);
final ja = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Alle Downloads entfernen?'),
content: Text('$anzahl Titel werden vom Gerät genommen. '
'Am Server bleiben sie unverändert.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Entfernen'),
),
],
),
);
if (ja != true) return;
await dienst.entferneAlle();
messenger.showSnackBar(
const SnackBar(content: Text('Downloads entfernt')),
);
}
Future<void> _einzelnEntfernen(BuildContext context, Download eintrag) async {
final dienst = context.read<DownloadService>();
final messenger = ScaffoldMessenger.of(context);
final ja = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Vom Gerät nehmen?'),
content: Text('${eintrag.titel}" wird vom Gerät genommen. '
'Am Server bleibt der Titel unverändert.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Entfernen'),
),
],
),
);
if (ja != true) return;
await dienst.entferne(eintrag.navidromeId);
messenger.showSnackBar(
SnackBar(content: Text('${eintrag.titel}" entfernt')),
);
}
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return Scaffold(
appBar: AppBar(title: const Text('Heruntergeladen')),
body: StreamBuilder<List<Download>>(
stream: db.watchDownloads(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: MeloTheme.text2)),
);
}
final eintraege = snapshot.data ?? const <Download>[];
if (eintraege.isEmpty) return const _Leer();
final gesamt =
eintraege.fold<int>(0, (summe, e) => summe + e.groesseBytes);
return Column(
children: [
ListTile(
title: Text('${eintraege.length} Titel · '
'${formatiereBytes(gesamt)}'),
subtitle: const Text(
'Diese Titel laufen ohne Netz und werden nie von selbst '
'gelöscht.'),
trailing: TextButton(
onPressed: () => _alleEntfernen(context, eintraege.length),
child: const Text('Alle entfernen'),
),
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
itemCount: eintraege.length,
itemBuilder: (context, i) {
final eintrag = eintraege[i];
return ListTile(
leading: const Icon(Icons.download_done),
title: Text(eintrag.titel,
maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
[
eintrag.kuenstler ?? 'Unbekannt',
formatiereBytes(eintrag.groesseBytes),
].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
tooltip: 'Vom Gerät nehmen',
icon: const Icon(Icons.delete_outline),
onPressed: () => _einzelnEntfernen(context, eintrag),
),
);
},
),
),
],
);
},
),
);
}
}
class _Leer extends StatelessWidget {
const _Leer();
@override
Widget build(BuildContext context) {
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: MeloSpace.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download_for_offline_outlined,
size: 64, color: Colors.white24),
SizedBox(height: MeloSpace.sm + MeloSpace.xs),
Text('Noch nichts heruntergeladen',
style: TextStyle(color: MeloTheme.text2)),
SizedBox(height: MeloSpace.xs + 2),
Text(
'Im Bereich Download → Server steht neben jedem Album und '
'Künstler ein Pfeil.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white30, fontSize: 12),
),
],
),
),
);
}
}
+461 -128
View File
@@ -1,6 +1,5 @@
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -11,18 +10,34 @@ import '../library/library_service.dart';
import '../player/audio_handler.dart';
import '../services/baka_auth.dart';
import '../services/media_store.dart';
import '../services/download_service.dart';
import '../services/navidrome_service.dart';
import '../services/server_neuheiten.dart';
import '../services/yt_download_service.dart';
import '../shared/sub_tabs.dart';
import '../shared/ueberschrift.dart';
import 'download_einordnung.dart';
import 'server_titel_screen.dart';
import '../shared/theme.dart';
/// Online-Tab: durchsucht den verbundenen Navidrome-Server. Abgespielte
/// Download-Tab: YouTube-Downloader und der verbundene Navidrome-Server
/// (Unterreiter). Abgespielte
/// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert
/// und stehen danach offline zur Verfügung.
class DownloadsScreen extends StatefulWidget {
const DownloadsScreen({super.key});
const DownloadsScreen({
super.key,
this.adresse,
this.onAdresseUebernommen,
});
/// Adresse aus einem „Teilen"-Aufruf. Wird ins Eingabefeld übernommen und
/// der YouTube-Unterreiter aufgeschlagen.
final String? adresse;
/// Meldet zurück, dass die Adresse angekommen ist — sie soll beim nächsten
/// Wechsel in diesen Bereich nicht erneut auftauchen.
final VoidCallback? onAdresseUebernommen;
@override
State<DownloadsScreen> createState() => _DownloadsScreenState();
@@ -32,27 +47,44 @@ class _DownloadsScreenState extends State<DownloadsScreen> {
/// 0 = YouTube, 1 = Server.
int _subTab = 0;
@override
void didUpdateWidget(DownloadsScreen alt) {
super.didUpdateWidget(alt);
// Eine geteilte Adresse gehört zum YouTube-Bereich; wer gerade beim
// Server war, soll trotzdem dort landen.
if (widget.adresse != null && widget.adresse != alt.adresse) {
setState(() => _subTab = 0);
}
}
@override
Widget build(BuildContext context) {
// Fortschrittsanzeigen stecken jeweils nur im aktiven Unterreiter — ohne
// diesen Punkt sah man nicht, dass im anderen Bereich gerade ein
// Download läuft, während man selbst woanders war.
final ytLaeuft = context.watch<YtDownloadService>().laeuft;
final serverLaeuft = context.watch<DownloadService>().laeuft;
return SafeArea(
bottom: false,
child: Column(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('Online',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
),
),
const Ueberschrift('Download'),
SubTabs(
labels: const ['YouTube', 'Server'],
index: _subTab,
badges: [
ytLaeuft && _subTab != 0,
serverLaeuft && _subTab != 1,
],
onChanged: (i) => setState(() => _subTab = i),
),
Expanded(
child: _subTab == 0 ? const _YouTubeBereich() : const _ServerBrowser(),
child: _subTab == 0
? _YouTubeBereich(
adresse: widget.adresse,
onAdresseUebernommen: widget.onAdresseUebernommen,
)
: const _ServerBrowser(),
),
],
),
@@ -72,9 +104,18 @@ class _ServerBrowserState extends State<_ServerBrowser> {
// Die Alben sind beim Prüfen schon geladen — kein zweiter Serveraufruf.
late final ServerNeuheiten _neuheiten =
ServerNeuheiten(holeAlben: () async => _albums);
static const _seite = 50;
List<SubsonicAlbum> _albums = [];
List<SubsonicArtist> _artists = [];
bool _loading = false;
bool _laedtNach = false;
// Solange die gespeicherten Zugangsdaten noch geladen werden, ist
// _nav.istVerbunden fälschlich noch false — ohne diesen Zustand blitzte
// beim Öffnen kurz "nicht verbunden" auf, obwohl der Server verbunden ist.
bool _pruefeZugangsdaten = true;
/// Der Server hat weniger als eine volle Seite geliefert — dann ist Schluss.
bool _alleAlbenDa = false;
bool _showArtists = false;
String? _error;
@@ -82,9 +123,9 @@ class _ServerBrowserState extends State<_ServerBrowser> {
void initState() {
super.initState();
_nav.ladeGespeicherteZugangsdaten().then((_) {
if (_nav.istVerbunden && mounted) {
_loadAlbums();
}
if (!mounted) return;
setState(() => _pruefeZugangsdaten = false);
if (_nav.istVerbunden) _loadAlbums();
});
}
@@ -97,10 +138,11 @@ class _ServerBrowserState extends State<_ServerBrowser> {
_error = null;
});
try {
final alben = await _nav.getAlben(anzahl: 50);
final alben = await _nav.getAlben(anzahl: _seite);
if (mounted) {
setState(() {
_albums = alben;
_alleAlbenDa = alben.length < _seite;
_loading = false;
});
// Erst jetzt prüfen — _albums ist gefüllt.
@@ -118,6 +160,29 @@ class _ServerBrowserState extends State<_ServerBrowser> {
}
}
/// Holt die nächste Seite Alben. Vorher blieb die Liste bei 50 stehen,
/// ohne dass irgendetwas darauf hinwies.
Future<void> _mehrAlben() async {
if (_laedtNach || _alleAlbenDa) return;
setState(() => _laedtNach = true);
try {
final weitere =
await _nav.getAlben(anzahl: _seite, ab: _albums.length);
if (!mounted) return;
setState(() {
_albums = [..._albums, ...weitere];
_alleAlbenDa = weitere.length < _seite;
_laedtNach = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _laedtNach = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Nachladen fehlgeschlagen: $e')),
);
}
}
Future<void> _loadArtists() async {
if (!_nav.istVerbunden) return;
setState(() {
@@ -145,6 +210,12 @@ class _ServerBrowserState extends State<_ServerBrowser> {
@override
Widget build(BuildContext context) {
if (_pruefeZugangsdaten) {
return const Center(
child: CircularProgressIndicator(color: MeloTheme.red),
);
}
if (!_nav.istVerbunden) {
return Center(
child: Column(
@@ -164,7 +235,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
if (_loading) {
return const Center(
child: CircularProgressIndicator(color: Colors.redAccent),
child: CircularProgressIndicator(color: MeloTheme.red),
);
}
@@ -178,7 +249,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
children: [
Icon(hatFehler ? Icons.error_outline : Icons.album,
size: 64,
color: hatFehler ? Colors.redAccent : Colors.white24),
color: hatFehler ? MeloTheme.red : Colors.white24),
const SizedBox(height: 12),
Text(
hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen',
@@ -199,6 +270,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
return Column(
children: [
const _LadeFortschritt(),
if (_neuheiten.neue.isNotEmpty)
MaterialBanner(
backgroundColor: MeloTheme.surfaceHigh,
@@ -228,9 +300,10 @@ class _ServerBrowserState extends State<_ServerBrowser> {
if (_albums.isEmpty) _loadAlbums();
}),
style: FilledButton.styleFrom(
backgroundColor: !_showArtists ? Colors.redAccent : Colors.grey.shade700,
backgroundColor:
!_showArtists ? MeloTheme.red : MeloTheme.surfaceHigh,
),
child: const Text('📀 Alben'),
child: const Text('Alben'),
),
),
const SizedBox(width: 8),
@@ -241,9 +314,10 @@ class _ServerBrowserState extends State<_ServerBrowser> {
if (_artists.isEmpty) _loadArtists();
}),
style: FilledButton.styleFrom(
backgroundColor: _showArtists ? Colors.redAccent : Colors.grey.shade700,
backgroundColor:
_showArtists ? MeloTheme.red : MeloTheme.surfaceHigh,
),
child: const Text('🎤 Künstler'),
child: const Text('Künstler'),
),
),
],
@@ -261,15 +335,34 @@ class _ServerBrowserState extends State<_ServerBrowser> {
child: ListTile(
leading: const Icon(Icons.person, size: 48),
title: Text(artist.name),
onTap: () => _playArtist(artist),
trailing: _LadeKnopf(
tooltip: 'Alles von ${artist.name} herunterladen',
holeTitel: () => _nav.getArtistSongs(artist.id),
),
onTap: () => _oeffneKuenstler(artist),
),
);
},
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: _albums.length,
itemCount: _albums.length + (_alleAlbenDa ? 0 : 1),
itemBuilder: (context, i) {
if (i == _albums.length) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: MeloSpace.md),
child: Center(
child: _laedtNach
? const CircularProgressIndicator(
color: MeloTheme.red)
: OutlinedButton(
onPressed: _mehrAlben,
child: const Text('Mehr Alben laden'),
),
),
);
}
final album = _albums[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
@@ -277,11 +370,15 @@ class _ServerBrowserState extends State<_ServerBrowser> {
leading: Icon(
Icons.album,
size: 48,
color: Colors.redAccent.withValues(alpha: 0.5),
color: MeloTheme.red.withValues(alpha: 0.5),
),
title: Text(album.name),
subtitle: Text('${album.songCount} Songs'),
onTap: () => _playAlbum(album),
subtitle: Text('${album.songCount} Titel'),
trailing: _LadeKnopf(
tooltip: 'Album herunterladen',
holeTitel: () => _nav.getSongs(album.id),
),
onTap: () => _oeffneAlbum(album),
),
);
},
@@ -291,74 +388,196 @@ class _ServerBrowserState extends State<_ServerBrowser> {
);
}
Future<void> _playAlbum(SubsonicAlbum album) async {
final handler = context.read<MeloAudioHandler>();
try {
final songs = await _nav.getSongs(album.id);
if (songs.isEmpty) {
if (!mounted) {
return;
}
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(content: Text('Album hat keine Songs')),
);
return;
}
final items = songs
.map((s) => MediaItem(
id: _nav.streamUrl(s.id).toString(),
title: s.titel,
artist: s.kuenstler,
album: s.album,
duration: Duration(seconds: s.dauerSekunden),
// Die Server-ID getrennt von der Abspiel-URI mitgeben: nur damit
// erkennt der Player einen Server-Titel (Cache, Scrobble, Bookmark).
extras: {'navidromeId': s.id},
))
.toList();
await handler.loadPlaylist(items);
} catch (e) {
if (!mounted) {
return;
}
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
SnackBar(content: Text('Fehler: $e')),
);
}
void _oeffneAlbum(SubsonicAlbum album) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => ServerTitelScreen(
titel: album.name,
untertitel: album.kuenstler,
coverId: album.coverId,
navidrome: _nav,
holeTitel: () => _nav.getSongs(album.id),
),
));
}
Future<void> _playArtist(SubsonicArtist artist) async {
final handler = context.read<MeloAudioHandler>();
void _oeffneKuenstler(SubsonicArtist artist) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => ServerTitelScreen(
titel: artist.name,
navidrome: _nav,
holeTitel: () => _nav.getArtistSongs(artist.id),
),
));
}
}
/// Knopf, der die Titel hinter einem Album oder Künstler herunterlädt.
///
/// [holeTitel] wird erst beim Antippen aufgerufen — die Titelliste eines
/// Albums kostet einen Serveraufruf, und den für jede sichtbare Zeile im
/// Voraus zu machen wäre Unfug.
class _LadeKnopf extends StatefulWidget {
const _LadeKnopf({required this.tooltip, required this.holeTitel});
final String tooltip;
final Future<List<SubsonicSong>> Function() holeTitel;
@override
State<_LadeKnopf> createState() => _LadeKnopfState();
}
class _LadeKnopfState extends State<_LadeKnopf> {
bool _holt = false;
Future<void> _antippen() async {
final dienst = context.read<DownloadService>();
final messenger = ScaffoldMessenger.of(context);
if (dienst.laeuft) {
messenger.showSnackBar(
const SnackBar(content: Text('Es läuft schon ein Download')),
);
return;
}
setState(() => _holt = true);
final List<SubsonicSong> titel;
try {
final songs = await _nav.getArtistSongs(artist.id);
if (songs.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Künstler hat keine Songs')),
);
return;
}
final items = songs
.map((s) => MediaItem(
id: _nav.streamUrl(s.id).toString(),
title: s.titel,
artist: s.kuenstler,
album: s.album,
duration: Duration(seconds: s.dauerSekunden),
// Die Server-ID getrennt von der Abspiel-URI mitgeben: nur damit
// erkennt der Player einen Server-Titel (Cache, Scrobble, Bookmark).
extras: {'navidromeId': s.id},
))
.toList();
await handler.loadPlaylist(items);
titel = await widget.holeTitel();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler: $e')),
if (mounted) setState(() => _holt = false);
messenger.showSnackBar(SnackBar(content: Text('Fehler: $e')));
return;
}
if (!mounted) return;
setState(() => _holt = false);
if (titel.isEmpty) {
messenger.showSnackBar(const SnackBar(content: Text('Nichts zu laden')));
return;
}
// Ein Album geht ohne Nachfrage durch; ein ganzer Künstler kann
// mehrere hundert Titel und einige Gigabyte sein.
if (brauchtRueckfrage(titel.length) && !await _bestaetigt(titel)) return;
// Zweite Prüfung: zwischen der ersten und jetzt lagen ein Serveraufruf
// und womöglich ein Dialog. In der Zeit kann ein anderer Knopf einen
// Download gestartet haben — `lade` liefert dann sofort 0, und ohne diese
// Prüfung meldete die App „War schon heruntergeladen".
if (dienst.laeuft) {
messenger.showSnackBar(
const SnackBar(content: Text('Es läuft schon ein Download')),
);
return;
}
final neu = await dienst.lade(titel);
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(_meldung(dienst, neu))));
}
/// Sagt beides: was ankam und was nicht. Ein einzelner Fehlschlag darf
/// nicht den Erfolg der übrigen neunzehn Titel verdecken.
static String _meldung(DownloadService dienst, int neu) {
final teile = <String>[];
if (neu > 0) teile.add('$neu Titel heruntergeladen');
if (dienst.fehlgeschlagen > 0) {
teile.add('${dienst.fehlgeschlagen} fehlgeschlagen');
}
if (teile.isEmpty) {
// Kein Titel, kein Fehlschlag: entweder war wirklich schon alles da,
// oder der Download ist gar nicht erst gestartet (z.B. keine
// Verbindung zum Server) — dann steckt der Grund in dienst.fehler.
return dienst.fehler ?? 'War schon heruntergeladen';
}
return teile.join(' · ');
}
Future<bool> _bestaetigt(List<SubsonicSong> titel) async {
final mb = geschaetzteBytes([for (final t in titel) t.dauerSekunden]) ~/
(1024 * 1024);
final ja = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Wirklich herunterladen?'),
content: Text('${titel.length} Titel, geschätzt etwa $mb MB.\n'
'Die Schätzung ist grob — der Server nennt die genaue Größe '
'erst beim Abruf.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Herunterladen'),
),
],
),
);
return ja ?? false;
}
@override
Widget build(BuildContext context) {
if (_holt) {
return const SizedBox(
width: MeloTheme.minTouchTarget,
height: MeloTheme.minTouchTarget,
child: Center(
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
return IconButton(
tooltip: widget.tooltip,
icon: const Icon(Icons.download_for_offline_outlined),
onPressed: _antippen,
);
}
}
/// Zeigt den laufenden Download über der Liste, mit Abbruch.
class _LadeFortschritt extends StatelessWidget {
const _LadeFortschritt();
@override
Widget build(BuildContext context) {
final dienst = context.watch<DownloadService>();
if (!dienst.laeuft) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.sm, MeloSpace.sm, 0),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lädt ${(dienst.fertig + 1).clamp(1, dienst.gesamt)} '
'von ${dienst.gesamt}'
'${dienst.aktuell == null ? '' : '${dienst.aktuell}'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: MeloTheme.text2, fontSize: 12),
),
const SizedBox(height: MeloSpace.xs),
LinearProgressIndicator(value: dienst.anteil),
],
),
),
TextButton(
onPressed: dienst.abbrechen,
child: const Text('Abbrechen'),
),
],
),
);
}
}
@@ -367,7 +586,10 @@ const _cookiesSchluessel = 'yt_cookies';
/// YouTube-Bereich: Anmeldung am Baka-Konto und Adressfeld für den Download.
class _YouTubeBereich extends StatefulWidget {
const _YouTubeBereich();
const _YouTubeBereich({this.adresse, this.onAdresseUebernommen});
final String? adresse;
final VoidCallback? onAdresseUebernommen;
@override
State<_YouTubeBereich> createState() => _YouTubeBereichState();
@@ -384,10 +606,49 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
/// Gewählter Datenträger; `null` = Android entscheidet (nur ein Ort da).
String? _volume;
/// Wer am Melo-Server angemeldet ist, braucht keine eigene
/// Baka-Anmeldung mehr — nur Gäste (kein Server-Account) sehen sie.
bool _serverUser = false;
/// Die automatische Anmeldung ist fehlgeschlagen (z. B. abweichendes
/// Baka-Passwort) — dann bleibt der manuelle Anmelde-Weg als Rückfalloption
/// sichtbar, sonst käme so ein User nie mehr an den Dialog heran.
bool _autoLoginFehlgeschlagen = false;
@override
void initState() {
super.initState();
_ladeEinstellungen();
_uebernimmAdresse();
_pruefeServerUser();
}
Future<void> _pruefeServerUser() async {
final nav = context.read<DownloadService>().navidrome;
final auth = context.read<BakaAuth>();
final ergebnis = await pruefeServerUser(nav, auth);
if (!mounted) return;
setState(() {
_serverUser = ergebnis.serverUser;
_autoLoginFehlgeschlagen = ergebnis.autoLoginFehlgeschlagen;
});
}
@override
void didUpdateWidget(_YouTubeBereich alt) {
super.didUpdateWidget(alt);
if (widget.adresse != null && widget.adresse != alt.adresse) {
_uebernimmAdresse();
}
}
/// Trägt eine geteilte Adresse ins Feld — ohne den Download zu starten.
/// Kategorie und Speicherort will man vorher noch wählen können.
void _uebernimmAdresse() {
final adresse = widget.adresse;
if (adresse == null) return;
_url.text = adresse;
widget.onAdresseUebernommen?.call();
}
Future<void> _ladeEinstellungen() async {
@@ -418,15 +679,10 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
}
Future<void> _anmelden() async {
final auth = context.read<BakaAuth>();
final daten = await showDialog<(String, String)>(
await showDialog(
context: context,
builder: (_) => const _AnmeldeDialog(),
);
if (daten == null || !mounted) return;
final fehler = await auth.anmelden(daten.$1, daten.$2);
if (!mounted) return;
setState(() => _meldung = fehler);
}
Future<void> _herunterladen() async {
@@ -481,38 +737,56 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
Widget build(BuildContext context) {
final auth = context.watch<BakaAuth>();
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);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: [
if (!auth.istAngemeldet) ...[
const Text(
'Der Downloader läuft über den Baka-Server. Dafür brauchst du '
'deine Baka-Anmeldung.',
style: TextStyle(color: MeloTheme.text2, fontSize: 13),
if (!zugriffOk) ...[
Text(
_autoLoginFehlgeschlagen
? 'Die automatische Anmeldung hat nicht geklappt — vermutlich '
'weicht dein Server-Passwort vom Baka-Konto ab. Einmal '
'manuell mit dem aktuellen Baka-Passwort anmelden, dann '
'gleicht sich das Passwort wieder ab.'
: 'Der Downloader läuft über den Baka-Server. Dafür brauchst '
'du deine Baka-Anmeldung.',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
),
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(Icons.login),
label: const Text('Beim Baka-Konto anmelden'),
onPressed: _anmelden,
SizedBox(
width: double.infinity,
height: MeloTheme.minTouchTarget + 4,
child: FilledButton.icon(
icon: const Icon(Icons.login),
label: const Text('Beim Baka-Konto anmelden',
style: TextStyle(fontSize: 16)),
onPressed: _anmelden,
),
),
] else ...[
Row(
children: [
const Icon(Icons.verified_user, size: 18, color: MeloTheme.text3),
const SizedBox(width: 8),
Expanded(
child: Text('Angemeldet als ${auth.benutzer}',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13)),
),
TextButton(
onPressed: auth.abmelden,
child: const Text('Abmelden'),
),
],
),
const SizedBox(height: 8),
if (!_serverUser) ...[
Row(
children: [
const Icon(Icons.verified_user, size: 18, color: MeloTheme.text3),
const SizedBox(width: 8),
Expanded(
child: Text('Angemeldet als ${auth.benutzer}',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13)),
),
TextButton(
onPressed: auth.abmelden,
child: const Text('Abmelden'),
),
],
),
const SizedBox(height: 8),
],
TextField(
controller: _url,
enabled: !dienst.laeuft,
@@ -563,13 +837,30 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
const SizedBox(height: 16),
const LinearProgressIndicator(),
const SizedBox(height: 8),
Text(dienst.status ?? 'Läuft …',
style: const TextStyle(color: MeloTheme.text2, fontSize: 12)),
Row(
children: [
Expanded(
child: Text(dienst.status ?? 'Läuft …',
style: const TextStyle(
color: MeloTheme.text2, fontSize: 12)),
),
// Ohne diesen Knopf war ein hängender Download (bis zu zwei
// mal 180s) nur durch Schließen der App zu stoppen.
TextButton(
onPressed: dienst.abbrechen,
child: const Text('Abbrechen'),
),
],
),
],
if (_meldung != null) ...[
const SizedBox(height: 16),
Text(_meldung!,
style: const TextStyle(color: MeloTheme.text2, fontSize: 13)),
style: TextStyle(
color: _meldung!.startsWith('')
? MeloTheme.text2
: MeloTheme.red,
fontSize: 13)),
],
],
);
@@ -588,6 +879,8 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
final _benutzer = TextEditingController(text: 'Baka');
final _passwort = TextEditingController();
bool _sichtbar = false;
bool _verbindet = false;
String? _fehler;
@override
void dispose() {
@@ -596,6 +889,24 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
super.dispose();
}
Future<void> _anmelden() async {
setState(() {
_verbindet = true;
_fehler = null;
});
final auth = context.read<BakaAuth>();
final fehler = await auth.anmelden(_benutzer.text.trim(), _passwort.text);
if (!mounted) return;
if (fehler == null) {
Navigator.pop(context);
return;
}
setState(() {
_verbindet = false;
_fehler = fehler;
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
@@ -605,10 +916,12 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
children: [
TextField(
controller: _benutzer,
enabled: !_verbindet,
decoration: const InputDecoration(labelText: 'Benutzername'),
),
TextField(
controller: _passwort,
enabled: !_verbindet,
obscureText: !_sichtbar,
decoration: InputDecoration(
labelText: 'Passwort',
@@ -618,17 +931,36 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
),
),
),
if (_fehler != null) ...[
const SizedBox(height: 8),
Text(_fehler!,
style: const TextStyle(color: MeloTheme.red, fontSize: 12)),
],
if (_verbindet)
const Padding(
padding: EdgeInsets.only(top: 12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: MeloTheme.red),
),
),
],
),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Abbrechen'),
style: TextButton.styleFrom(
minimumSize: const Size(88, MeloTheme.minTouchTarget)),
onPressed: _verbindet ? null : () => Navigator.pop(context),
child: const Text('Abbrechen', style: TextStyle(fontSize: 16)),
),
FilledButton(
onPressed: () => Navigator.pop(
context, (_benutzer.text.trim(), _passwort.text)),
child: const Text('Anmelden'),
style: FilledButton.styleFrom(
minimumSize: const Size(120, MeloTheme.minTouchTarget)),
onPressed: _verbindet ? null : _anmelden,
child: const Text('Anmelden', style: TextStyle(fontSize: 16)),
),
],
);
@@ -671,3 +1003,4 @@ class _KategorieFeld extends StatelessWidget {
);
}
}
+388
View File
@@ -0,0 +1,388 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../library/database.dart';
import '../library/song_media.dart';
import '../player/audio_handler.dart';
import '../services/download_service.dart';
import '../services/navidrome_service.dart';
import '../shared/cover.dart';
import '../shared/lauf_balken.dart';
import '../shared/theme.dart';
/// Die Titel eines Albums oder Künstlers vom Server.
///
/// Vorher spielte ein Tipp auf ein Album sofort das ganze Album ab — es gab
/// keine Möglichkeit, hineinzusehen oder einen einzelnen Titel zu wählen.
/// Das ist der Unterschied zwischen „abspielen" und „öffnen", und jede andere
/// Musik-App trennt ihn.
class ServerTitelScreen extends StatefulWidget {
const ServerTitelScreen({
super.key,
required this.titel,
required this.navidrome,
required this.holeTitel,
this.untertitel,
this.coverId,
});
final String titel;
final String? untertitel;
final String? coverId;
final NavidromeService navidrome;
final Future<List<SubsonicSong>> Function() holeTitel;
@override
State<ServerTitelScreen> createState() => _ServerTitelScreenState();
}
class _ServerTitelScreenState extends State<ServerTitelScreen> {
List<SubsonicSong>? _songs;
Set<String> _geladen = const {};
String? _fehler;
@override
void initState() {
super.initState();
_laden();
}
Future<void> _laden() async {
final db = context.read<MeloDb>();
setState(() => _fehler = null);
try {
final songs = await widget.holeTitel();
// Einmal für den ganzen Bildschirm, nicht je Zeile.
final geladen = await db.downloadIds();
if (mounted) {
setState(() {
_songs = songs;
_geladen = geladen;
});
}
} catch (e) {
if (mounted) {
setState(() => _fehler = e is NavidromeException
? e.message
: 'Titel nicht abrufbar');
}
}
}
/// Nach Laden oder Entfernen ist der Einmal-Schnappschuss [_geladen] veraltet
/// — hier wird er nachgezogen.
Future<void> _aktualisiereGeladen() async {
final geladen = await context.read<MeloDb>().downloadIds();
if (mounted) setState(() => _geladen = geladen);
}
Future<void> _offlineNehmen(SubsonicSong song) async {
final dienst = context.read<DownloadService>();
final messenger = ScaffoldMessenger.of(context);
if (dienst.laeuft) {
messenger.showSnackBar(
const SnackBar(content: Text('Es läuft schon ein Download')),
);
return;
}
final neu = await dienst.ladeEinzelnenTitel(song);
await _aktualisiereGeladen();
if (!mounted) return;
messenger.showSnackBar(SnackBar(
content: Text(neu
? 'Offline: ${song.titel}'
: (dienst.fehler ?? 'War schon heruntergeladen')),
));
}
Future<void> _offlineEntfernen(SubsonicSong song) async {
final dienst = context.read<DownloadService>();
final messenger = ScaffoldMessenger.of(context);
final weg = await dienst.entferne(song.id);
await _aktualisiereGeladen();
if (!mounted) return;
messenger.showSnackBar(SnackBar(
content: Text(weg
? 'Vom Gerät entfernt: ${song.titel}'
: (dienst.fehler ?? 'Ließ sich nicht entfernen')),
));
}
Future<void> _spiele(List<SubsonicSong> songs, int ab) async {
final handler = context.read<MeloAudioHandler>();
final messenger = ScaffoldMessenger.of(context);
final items = [
for (final s in songs) subsonicToMediaItem(s, widget.navidrome)
];
try {
final uebersprungen = await handler.loadPlaylist(items, startIndex: ab);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
);
}
} on NichtsOfflineVerfuegbar catch (e) {
messenger.showSnackBar(SnackBar(content: Text('$e')));
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
}
Future<void> _zufaellig(List<SubsonicSong> songs) {
final gemischt = [...songs]..shuffle();
return _spiele(gemischt, 0);
}
@override
Widget build(BuildContext context) {
final songs = _songs;
return Scaffold(
appBar: AppBar(title: Text(widget.titel)),
body: _fehler != null
? _Fehler(text: _fehler!, onNochmal: _laden)
: songs == null
? const Center(
child: CircularProgressIndicator(color: MeloTheme.red))
: songs.isEmpty
? const Center(
child: Text('Keine Titel',
style: TextStyle(color: MeloTheme.text2)))
: ListView.builder(
itemCount: songs.length + 1,
itemBuilder: (context, i) {
if (i == 0) {
return _Kopf(
titel: widget.titel,
untertitel: widget.untertitel,
coverId: widget.coverId,
navidrome: widget.navidrome,
songs: songs,
onAbspielen: () => _spiele(songs, 0),
onZufaellig: () => _zufaellig(songs),
);
}
final song = songs[i - 1];
return _Zeile(
song: song,
nummer: i,
geladen: _geladen.contains(song.id),
onTap: () => _spiele(songs, i - 1),
onOffline: () => _offlineNehmen(song),
onEntfernen: () => _offlineEntfernen(song),
);
},
),
);
}
}
/// Cover, Anzahl, Gesamtdauer und die beiden Wiedergabe-Knöpfe.
class _Kopf extends StatelessWidget {
const _Kopf({
required this.titel,
required this.untertitel,
required this.coverId,
required this.navidrome,
required this.songs,
required this.onAbspielen,
required this.onZufaellig,
});
final String titel;
final String? untertitel;
final String? coverId;
final NavidromeService navidrome;
final List<SubsonicSong> songs;
final VoidCallback onAbspielen;
final VoidCallback onZufaellig;
static String _dauer(int sekunden) {
final stunden = sekunden ~/ 3600;
final minuten = (sekunden % 3600) ~/ 60;
if (stunden > 0) return '$stunden Std. $minuten Min.';
return '$minuten Min.';
}
@override
Widget build(BuildContext context) {
final gesamt = songs.fold<int>(0, (s, e) => s + e.dauerSekunden);
final bild = coverId;
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.sm, MeloSpace.md, MeloSpace.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (bild != null)
CoverImage(
artUri: navidrome.coverUrl(bild),
size: 180,
radius: MeloRadius.card,
),
const SizedBox(height: MeloSpace.sm + MeloSpace.xs),
Text(titel,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center),
const SizedBox(height: MeloSpace.xs),
Text(
[
?untertitel,
'${songs.length} Titel',
_dauer(gesamt),
].join(' · '),
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
textAlign: TextAlign.center,
),
const SizedBox(height: MeloSpace.md),
Row(
children: [
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.play_arrow),
label: const Text('Abspielen'),
onPressed: onAbspielen,
),
),
const SizedBox(width: MeloSpace.sm),
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.shuffle),
label: const Text('Zufällig'),
onPressed: onZufaellig,
),
),
],
),
const SizedBox(height: MeloSpace.sm),
const Divider(height: 1),
],
),
);
}
}
/// Eine Titelzeile: Nummer, Name, Dauer — und die Lauf-Markierung, wenn
/// dieser Titel gerade spielt.
class _Zeile extends StatelessWidget {
const _Zeile({
required this.song,
required this.nummer,
required this.geladen,
required this.onTap,
required this.onOffline,
required this.onEntfernen,
});
final SubsonicSong song;
final int nummer;
final bool geladen;
final VoidCallback onTap;
final VoidCallback onOffline;
final VoidCallback onEntfernen;
static String _dauer(int sekunden) {
final m = (sekunden ~/ 60).toString();
final s = (sekunden % 60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
return StreamBuilder<MediaItem?>(
stream: handler.mediaItem,
initialData: handler.mediaItem.valueOrNull,
builder: (context, laufSnap) {
// Server-Titel tragen keine Bibliotheks-UUID; erkannt werden sie an
// der Navidrome-ID.
final aktuell = laufSnap.data;
final markiert =
aktuell != null && navidromeIdOf(aktuell) == song.id;
return StreamBuilder<PlaybackState>(
stream: markiert ? handler.playbackState : null,
initialData: handler.playbackState.valueOrNull,
builder: (context, zustandSnap) => ListTile(
leading: SizedBox(
width: 32,
child: markiert
? LaufBalken(
laeuft: zustandSnap.data?.playing ?? false,
farbe: MeloTheme.red,
)
: Text('$nummer',
textAlign: TextAlign.center,
style: const TextStyle(color: MeloTheme.text3)),
),
title: Text(
song.titel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: markiert
? const TextStyle(
color: MeloTheme.red, fontWeight: FontWeight.w600)
: null,
),
subtitle: song.kuenstler.isEmpty
? null
: Text(song.kuenstler,
maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bisher gab es den Lade-Knopf nur je Album — einen einzelnen
// Titel mitzunehmen ging gar nicht.
geladen
? IconButton(
tooltip: 'Vom Gerät entfernen',
icon: const Icon(Icons.download_done,
size: 20, color: MeloTheme.red),
onPressed: onEntfernen,
)
: IconButton(
tooltip: 'Offline nehmen',
icon: const Icon(Icons.download_outlined,
size: 20, color: MeloTheme.text3),
onPressed: onOffline,
),
Text(_dauer(song.dauerSekunden),
style: const TextStyle(color: MeloTheme.text3)),
],
),
onTap: onTap,
),
);
},
);
}
}
class _Fehler extends StatelessWidget {
const _Fehler({required this.text, required this.onNochmal});
final String text;
final VoidCallback onNochmal;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: MeloTheme.red),
const SizedBox(height: MeloSpace.sm + MeloSpace.xs),
Text(text, style: const TextStyle(color: MeloTheme.text2)),
const SizedBox(height: MeloSpace.md),
FilledButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
onPressed: onNochmal,
),
],
),
);
}
}
+268
View File
@@ -0,0 +1,268 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../library/database.dart';
import '../library/library_service.dart';
import '../services/baka_auth.dart';
import '../services/download_service.dart';
import '../services/media_store.dart';
import '../services/yt_download_service.dart';
import '../services/yt_search_service.dart';
import '../shared/theme.dart';
import 'download_einordnung.dart';
/// Extra-Tab (zum Testen): YouTube direkt in der App durchsuchen, statt nur
/// eine Adresse einzufügen. Ergebnisse zeigen Vorschaubild, Titel und Dauer;
/// der Download-Knopf nutzt denselben Baka-Proxy-Weg wie der Download-Tab.
class YoutubeSearchScreen extends StatefulWidget {
const YoutubeSearchScreen({super.key});
@override
State<YoutubeSearchScreen> createState() => _YoutubeSearchScreenState();
}
class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
final _query = TextEditingController();
/// Server-User (siehe _YouTubeBereich in downloads_screen.dart) brauchen
/// hier keinen Anmelde-Hinweis — die Anmeldung passiert im Hintergrund.
bool _serverUser = false;
/// Die automatische Anmeldung ist fehlgeschlagen — dann bleibt der
/// Hinweis auf die manuelle Anmeldung (siehe Download-Tab) sichtbar.
bool _autoLoginFehlgeschlagen = false;
@override
void initState() {
super.initState();
_pruefeServerUser();
}
Future<void> _pruefeServerUser() async {
final nav = context.read<DownloadService>().navidrome;
final auth = context.read<BakaAuth>();
final ergebnis = await pruefeServerUser(nav, auth);
if (!mounted) return;
setState(() {
_serverUser = ergebnis.serverUser;
_autoLoginFehlgeschlagen = ergebnis.autoLoginFehlgeschlagen;
});
}
@override
void dispose() {
_query.dispose();
super.dispose();
}
void _suchen() {
final q = _query.text.trim();
if (q.isEmpty) return;
FocusScope.of(context).unfocus();
context.read<YtSearchService>().suchen(q);
}
@override
Widget build(BuildContext context) {
final auth = context.watch<BakaAuth>();
final suche = context.watch<YtSearchService>();
final zugriffOk =
auth.istAngemeldet || (_serverUser && !_autoLoginFehlgeschlagen);
return SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _query,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _suchen(),
decoration: const InputDecoration(
hintText: 'YouTube durchsuchen …',
prefixIcon: Icon(Icons.search),
),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _suchen,
child: const Text('Suchen'),
),
],
),
),
if (!zugriffOk)
const Padding(
padding: 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),
),
),
if (suche.laeuft) const LinearProgressIndicator(),
if (suche.fehler != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(suche.fehler!,
style: const TextStyle(color: Colors.redAccent)),
),
Expanded(
child: suche.treffer.isEmpty
? const _Empty()
: ListView.builder(
padding: const EdgeInsets.only(bottom: 16),
itemCount: suche.treffer.length,
itemBuilder: (context, i) =>
_TrefferZeile(treffer: suche.treffer[i]),
),
),
],
),
);
}
}
class _Empty extends StatelessWidget {
const _Empty();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.smart_display_outlined,
size: 64, color: Colors.white24),
const SizedBox(height: 12),
const Text('Nach Musikvideos suchen',
style: TextStyle(color: MeloTheme.text2)),
],
),
);
}
}
class _TrefferZeile extends StatefulWidget {
const _TrefferZeile({required this.treffer});
final YtSuchTreffer treffer;
@override
State<_TrefferZeile> createState() => _TrefferZeileState();
}
class _TrefferZeileState extends State<_TrefferZeile> {
bool _laedt = false;
Future<void> _herunterladen() async {
final dienst = context.read<YtDownloadService>();
final lib = context.read<LibraryService>();
final db = context.read<MeloDb>();
final messenger = ScaffoldMessenger.of(context);
setState(() => _laedt = true);
// Erst in einen App-eigenen Zwischenordner — siehe Download-Tab
// (_YouTubeBereich in downloads_screen.dart) für denselben Ablauf.
final zwischen = Directory.systemTemp.createTempSync('melo_yt').path;
final ergebnis =
await dienst.herunterladen(widget.treffer.url, zielOrdner: zwischen);
if (!mounted) return;
if (ergebnis == null) {
setState(() => _laedt = false);
messenger.showSnackBar(
SnackBar(content: Text(dienst.fehler ?? 'Fehlgeschlagen')));
return;
}
final pfad = await const MediaStore().veroeffentliche(
quellPfad: ergebnis.dateiPfad,
titel: ergebnis.titel,
kuenstler: ergebnis.kuenstler,
);
if (!mounted) return;
setState(() => _laedt = false);
if (pfad == null) {
messenger.showSnackBar(const SnackBar(
content:
Text('Heruntergeladen, aber nicht im Musikordner ablegbar')));
return;
}
// Der MediaStore kennt die Datei jetzt; ein Scan holt sie in die
// Bibliothek. Ohne gewählte Kategorie, wie bei einem schnellen Fund.
await lib.rescan();
await ordneDownloadEin(db, pfad, '');
if (mounted) {
messenger.showSnackBar(
SnackBar(content: Text('${ergebnis.titel} ist in deiner Musik')));
}
}
@override
Widget build(BuildContext context) {
final t = widget.treffer;
return ListTile(
leading: SizedBox(
width: 96,
height: 54,
child: t.thumbnailUrl == null
? const _ThumbnailPlatzhalter()
: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
t.thumbnailUrl!,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const _ThumbnailPlatzhalter(),
),
),
),
title: Text(t.title, maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: Text(_dauerLabel(t.duration)),
trailing: _laedt
? const SizedBox(
width: MeloTheme.minTouchTarget,
height: MeloTheme.minTouchTarget,
child: Center(
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
: IconButton(
tooltip: 'Herunterladen',
icon: const Icon(Icons.download_for_offline_outlined),
onPressed: _herunterladen,
),
);
}
}
class _ThumbnailPlatzhalter extends StatelessWidget {
const _ThumbnailPlatzhalter();
@override
Widget build(BuildContext context) {
return const ColoredBox(
color: MeloTheme.surfaceHigh,
child: Center(
child: Icon(Icons.smart_display_outlined, color: Colors.white24),
),
);
}
}
/// "3:07" — Minuten:Sekunden, wie überall sonst in der App.
String _dauerLabel(Duration d) {
final minuten = d.inMinutes;
final sekunden = d.inSeconds % 60;
return '$minuten:${sekunden.toString().padLeft(2, '0')}';
}
+3 -1
View File
@@ -56,7 +56,9 @@ Future<int> scanAndroidMediaStore(
var coverPath = prev?.coverPath;
if (coverPath == null) {
try {
final art = await audioQuery.queryArtwork(s.id, ArtworkType.AUDIO, size: 512);
// 1024px statt 512px: auf dem Sperrbildschirm (MIUI & Co.) erscheint
// das Cover großflächig — bei 512px wirkte es dort weich/verpixelt.
final art = await audioQuery.queryArtwork(s.id, ArtworkType.AUDIO, size: 1024);
if (art != null && art.isNotEmpty) {
final f = File(p.join(covers.path, '$id.img'));
await f.writeAsBytes(art);
+197 -23
View File
@@ -1,25 +1,41 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../settings/app_settings.dart';
import '../shared/auswahl.dart';
import '../shared/auswahl_leiste.dart';
import '../shared/cover.dart';
import '../shared/theme.dart';
import '../shared/titel_listen_screen.dart';
import 'category_service.dart';
import 'database.dart';
import 'song_grouping.dart';
import 'song_list.dart';
import '../shared/theme.dart';
/// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs].
class ArtistListScreen extends StatelessWidget {
class ArtistListScreen extends StatefulWidget {
const ArtistListScreen({super.key});
@override
State<ArtistListScreen> createState() => _ArtistListScreenState();
}
class _ArtistListScreenState extends State<ArtistListScreen> {
// Einmal anlegen, nicht in jedem build: die Zeilen hängen an
// CategoryService, und ein neuer Strom je Meldung würde die Abfrage
// während eines Scans laufend neu ausführen.
late final Stream<List<Song>> _songs = context.read<MeloDb>().watchSongs();
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return StreamBuilder<List<Song>>(
stream: db.watchSongs(),
stream: _songs,
builder: (context, snapshot) {
final songs = snapshot.data ?? const [];
if (songs.isEmpty) {
return const Center(
child: Text('Keine Künstler', style: TextStyle(color: MeloTheme.text2)),
child: Text('Keine Künstler',
style: TextStyle(color: MeloTheme.text2)),
);
}
final grouped = groupByArtist(songs);
@@ -28,17 +44,7 @@ class ArtistListScreen extends StatelessWidget {
itemCount: artists.length,
itemBuilder: (context, i) {
final artist = artists[i];
final artistSongs = grouped[artist]!;
return ListTile(
title: Text(artist, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text('${artistSongs.length} Songs'),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => _ArtistSongsScreen(artist: artist, songs: artistSongs),
),
),
);
return _KuenstlerZeile(name: artist, songs: grouped[artist]!);
},
);
},
@@ -46,17 +52,185 @@ class ArtistListScreen extends StatelessWidget {
}
}
/// Zeigt alle Songs eines Künstlers.
class _ArtistSongsScreen extends StatelessWidget {
const _ArtistSongsScreen({required this.artist, required this.songs});
final String artist;
/// Eine Künstlerzeile. Eigenes Widget, damit nur sie an [CategoryService]
/// hängt und nicht der Bildschirm mit seinem Datenbank-Strom.
class _KuenstlerZeile extends StatelessWidget {
const _KuenstlerZeile({required this.name, required this.songs});
final String name;
final List<Song> songs;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(artist)),
body: SongList(songs),
final kategorien = context.watch<CategoryService>();
final einstellungen = context.watch<AppSettings>();
final cover = kategorien.coverFor(songs.first,
groupByCategory: einstellungen.groupCoversByCategory);
return ListTile(
// Ein Bild statt einer leeren Fläche: die Liste war reiner Text und
// dadurch schwer zu überfliegen.
leading: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
),
title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle:
Text(kuenstlerUntertitel(songs.length, groupByAlbum(songs).length)),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => KuenstlerScreen(name: name, songs: songs),
)),
);
}
}
/// "12 Titel · 3 Alben" — die Albenzahl nur, wenn es mehr als eines ist.
/// „Titel" hat im Deutschen keine eigene Mehrzahl, deshalb ohne Fallunterscheidung.
String kuenstlerUntertitel(int titel, int alben) =>
alben > 1 ? '$titel Titel · $alben Alben' : '$titel Titel';
/// Die Seite eines Künstlers: seine Alben, darunter alle Titel.
///
/// Vorher führte ein Künstler direkt auf eine flache Titelliste — bei einem
/// Künstler mit acht Alben stand alles ungeordnet untereinander.
class KuenstlerScreen extends StatefulWidget {
const KuenstlerScreen({super.key, required this.name, required this.songs});
final String name;
final List<Song> songs;
@override
State<KuenstlerScreen> createState() => _KuenstlerScreenState();
}
class _KuenstlerScreenState extends State<KuenstlerScreen> {
/// Song-IDs der gerade ausgewählten Titel in „Alle Titel" — leer heißt:
/// kein Auswahl-Modus. Wie in SortableSongList, damit langes Drücken auf
/// einer Songzeile überall in der App dasselbe tut.
Set<String> _auswahl = const {};
bool get _waehltAus => _auswahl.isNotEmpty;
void _wechsleAuswahl(String songId) =>
setState(() => _auswahl = auswahlNach(_auswahl, songId));
void _beendeAuswahl() => setState(() => _auswahl = const {});
List<Song> get _gewaehlteSongs =>
[for (final s in widget.songs) if (_auswahl.contains(s.id)) s];
Future<void> _inWiedergabeliste() async {
await fuegeZuWiedergabelisteHinzu(context, _gewaehlteSongs);
if (mounted) _beendeAuswahl();
}
Future<void> _inWarteschlange() async {
await fuegeZuWarteschlangeHinzu(context, _gewaehlteSongs);
if (mounted) _beendeAuswahl();
}
@override
Widget build(BuildContext context) {
final alben = groupByAlbum(widget.songs);
// Bei einem einzigen Album wäre die Zwischenstufe nur ein Klick mehr.
final zeigeAlben = alben.length > 1;
final albumListe = zeigeAlben ? alben.entries.toList() : const [];
// Kopf „Alben" + Alben + Trenner + Kopf „Alle Titel"
final vorspann = zeigeAlben ? albumListe.length + 3 : 0;
return PopScope(
// Zurück beendet zuerst die Auswahl, wie in SortableSongList — sonst
// verlöre man die Auswahl mit derselben Geste, die überall sonst
// „einen Schritt zurück" bedeutet.
canPop: !_waehltAus,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && _waehltAus) _beendeAuswahl();
},
child: Scaffold(
appBar: _waehltAus
? AppBar(
automaticallyImplyLeading: false,
titleSpacing: 0,
title: AuswahlLeiste(
anzahl: _auswahl.length,
onAbbrechen: _beendeAuswahl,
onWiedergabeliste: _inWiedergabeliste,
onWarteschlange: _inWarteschlange,
),
)
: AppBar(title: Text(widget.name)),
// Bewusst `builder` und nicht `ListView(children: …)`: jede Songzeile
// hört auf den Player und öffnet für ihr Herz eine eigene Abfrage.
// Bei einem Künstler mit 300 Titeln wären das 300 Abos beim Öffnen
// der Seite.
body: ListView.builder(
itemCount: vorspann + widget.songs.length,
itemBuilder: (context, i) {
if (zeigeAlben) {
if (i == 0) return const _Kopf('Alben');
if (i <= albumListe.length) {
final eintrag = albumListe[i - 1];
return _AlbumZeile(name: eintrag.key, songs: eintrag.value);
}
if (i == albumListe.length + 1) return const Divider(height: 1);
if (i == albumListe.length + 2) return const _Kopf('Alle Titel');
}
final index = i - vorspann;
final song = widget.songs[index];
return SongZeile(
song: song,
warteschlange: widget.songs,
index: index,
auswahlModus: _waehltAus,
ausgewaehlt: _auswahl.contains(song.id),
onAuswahlWechsel: () => _wechsleAuswahl(song.id),
);
},
),
),
);
}
}
class _AlbumZeile extends StatelessWidget {
const _AlbumZeile({required this.name, required this.songs});
final String name;
final List<Song> songs;
@override
Widget build(BuildContext context) {
final kategorien = context.watch<CategoryService>();
final einstellungen = context.watch<AppSettings>();
final cover = kategorien.coverFor(songs.first,
groupByCategory: einstellungen.groupCoversByCategory);
return ListTile(
leading: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
),
title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text('${songs.length} Titel'),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => TitelListenScreen(titel: name, songs: songs),
)),
);
}
}
class _Kopf extends StatelessWidget {
const _Kopf(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.md, MeloSpace.md, MeloSpace.xs),
child: Text(text.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge),
);
}
}
+2 -17
View File
@@ -6,7 +6,7 @@ import '../shared/cover.dart';
import 'categories.dart';
import 'category_service.dart';
import 'database.dart';
import 'song_list.dart';
import '../shared/titel_listen_screen.dart';
import '../shared/theme.dart';
/// Bibliotheks-Reiter „Kategorie": alle Kategorien der Bibliothek.
@@ -57,7 +57,7 @@ class CategoryListScreen extends StatelessWidget {
context,
MaterialPageRoute(
builder: (_) =>
_KategorieLiederScreen(name: name, songs: lieder),
TitelListenScreen(titel: name, songs: lieder),
),
),
);
@@ -67,18 +67,3 @@ class CategoryListScreen extends StatelessWidget {
);
}
}
/// Zeigt alle Lieder einer Kategorie.
class _KategorieLiederScreen extends StatelessWidget {
const _KategorieLiederScreen({required this.name, required this.songs});
final String name;
final List<Song> songs;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(name)),
body: SongList(songs),
);
}
}
+91 -18
View File
@@ -72,6 +72,10 @@ class Playlists extends Table {
IntColumn get updatedAtMs => integer()();
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
/// ID derselben Playlist am Melo-Server, sobald sie einmal gesichert
/// wurde. `null` heißt: nur auf diesem Gerät.
TextColumn get cloudId => text().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -117,6 +121,23 @@ class PlaybackHistory extends Table {
Set<Column> get primaryKey => {songId, playedAtMs};
}
/// Bewusst heruntergeladene Server-Titel.
///
/// Die Dateien allein würden nicht reichen: sie heißen nach einem Hash, und
/// ohne diese Zeilen könnte die App nur „37 Dateien, 412 MB" zeigen statt
/// einer Liste mit Titeln und Künstlern.
class Downloads extends Table {
TextColumn get navidromeId => text()();
TextColumn get titel => text()();
TextColumn get kuenstler => text().nullable()();
TextColumn get album => text().nullable()();
IntColumn get groesseBytes => integer()();
IntColumn get geladenAmMs => integer()();
@override
Set<Column> get primaryKey => {navidromeId};
}
@DriftDatabase(tables: [
Songs,
Folders,
@@ -125,12 +146,13 @@ class PlaybackHistory extends Table {
Favorites,
PlaybackHistory,
SongCategories,
Downloads,
])
class MeloDb extends _$MeloDb {
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
@override
int get schemaVersion => 9;
int get schemaVersion => 11;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -169,6 +191,12 @@ class MeloDb extends _$MeloDb {
// Dateien ändert.
await _ergaenzeAlbumKategorien();
}
if (from < 10) {
await m.createTable(downloads);
}
if (from < 11) {
await m.addColumn(playlists, playlists.cloudId);
}
},
);
@@ -191,23 +219,6 @@ class MeloDb extends _$MeloDb {
.watch();
}
/// Sucht Songs nach Titel, Künstler oder Album. Wildcards werden escaped.
Stream<List<Song>> searchSongs(String query) {
final escaped = query
.toLowerCase()
.replaceAll('\\', '\\\\')
.replaceAll('%', '\\%')
.replaceAll('_', '\\_');
final like = '%$escaped%';
return (select(songs)
..where((s) =>
s.deleted.equals(false) &
(s.title.lower().like(like, escapeChar: '\\') |
s.artist.lower().like(like, escapeChar: '\\') |
s.album.lower().like(like, escapeChar: '\\')))
..orderBy([(s) => OrderingTerm(expression: s.title)]))
.watch();
}
/// Alle Songs inkl. getombstonte. Intern für ID-Stabilität beim Scan.
Future<List<Song>> allSongs() => select(songs).get();
@@ -280,6 +291,26 @@ class MeloDb extends _$MeloDb {
);
}
Future<Playlist?> playlistById(String id) =>
(select(playlists)..where((p) => p.id.equals(id))).getSingleOrNull();
Future<void> setPlaylistCloudId(String id, String cloudId) async {
await (update(playlists)..where((p) => p.id.equals(id)))
.write(PlaylistsCompanion(cloudId: Value(cloudId)));
}
/// Wie viele Playlisten es hier gibt — **inklusive Grabsteinen**.
///
/// Grundlage der Wiederherstellung: nur eine wirklich leere Tabelle gilt
/// als Neuinstallation. Wer alle Playlisten selbst gelöscht hat, soll sie
/// nicht vom Server zurückbekommen.
Future<int> countPlaylists() async {
final zaehler = playlists.id.count();
final zeile = await (selectOnly(playlists)..addColumns([zaehler]))
.getSingle();
return zeile.read(zaehler) ?? 0;
}
// === PlaylistSongs ===
Stream<List<Song>> watchPlaylistSongs(String playlistId) {
final query = select(songs).join([
@@ -457,6 +488,11 @@ class MeloDb extends _$MeloDb {
));
}
/// Der Song mit dieser UUID, oder `null`. Grundlage dafür, aus einer
/// Adresse im Auto wieder einen Titel zu machen.
Future<Song?> songById(String id) =>
(select(songs)..where((s) => s.id.equals(id))).getSingleOrNull();
/// Der Song mit genau diesem Dateipfad, oder `null`.
Future<Song?> songByPath(String path) =>
(select(songs)..where((s) => s.path.equals(path))).getSingleOrNull();
@@ -510,6 +546,14 @@ class MeloDb extends _$MeloDb {
.write(SongsCompanion(cloudId: Value(cloudId)));
}
Future<Song?> songByCloudId(String cloudId) async {
final treffer = await (select(songs)
..where((s) => s.cloudId.equals(cloudId))
..limit(1))
.get();
return treffer.isEmpty ? null : treffer.single;
}
/// Titel, die am Server gelöscht wurden, auch auf dem Gerät als gelöscht
/// markieren. Grabstein statt echtem Löschen — sonst legt der nächste Scan
/// sie wieder an.
@@ -541,6 +585,35 @@ class MeloDb extends _$MeloDb {
.get();
}
// === Downloads ===
Stream<List<Download>> watchDownloads() => (select(downloads)
..orderBy([
(d) => OrderingTerm(expression: d.geladenAmMs, mode: OrderingMode.desc)
]))
.watch();
Future<void> merkeDownload(DownloadsCompanion eintrag) =>
into(downloads).insert(eintrag, mode: InsertMode.insertOrReplace);
Future<void> vergissDownload(String navidromeId) => (delete(downloads)
..where((d) => d.navidromeId.equals(navidromeId)))
.go();
Future<void> vergissAlleDownloads() => delete(downloads).go();
/// Server-IDs aller heruntergeladenen Titel.
///
/// Einmal je Bildschirm statt einmal je Zeile: die Titelliste eines Albums
/// zeichnet sich bei jedem Positionswechsel neu, und eine Datei-Abfrage pro
/// Zeile und Neuzeichnen wäre eine Prüfung pro Sekunde und Titel.
Future<Set<String>> downloadIds() async {
final zeilen = await (selectOnly(downloads)
..addColumns([downloads.navidromeId]))
.get();
return {for (final z in zeilen) z.read(downloads.navidromeId)!};
}
Future<int?> lastPosition(String songId) async {
final row = await (select(playbackHistory)
..where((h) => h.songId.equals(songId))
+738 -4
View File
@@ -1322,6 +1322,17 @@ class $PlaylistsTable extends Playlists
),
defaultValue: const Constant(false),
);
static const VerificationMeta _cloudIdMeta = const VerificationMeta(
'cloudId',
);
@override
late final GeneratedColumn<String> cloudId = GeneratedColumn<String>(
'cloud_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> get $columns => [
id,
@@ -1330,6 +1341,7 @@ class $PlaylistsTable extends Playlists
createdAtMs,
updatedAtMs,
deleted,
cloudId,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -1393,6 +1405,12 @@ class $PlaylistsTable extends Playlists
deleted.isAcceptableOrUnknown(data['deleted']!, _deletedMeta),
);
}
if (data.containsKey('cloud_id')) {
context.handle(
_cloudIdMeta,
cloudId.isAcceptableOrUnknown(data['cloud_id']!, _cloudIdMeta),
);
}
return context;
}
@@ -1426,6 +1444,10 @@ class $PlaylistsTable extends Playlists
DriftSqlType.bool,
data['${effectivePrefix}deleted'],
)!,
cloudId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}cloud_id'],
),
);
}
@@ -1442,6 +1464,10 @@ class Playlist extends DataClass implements Insertable<Playlist> {
final int createdAtMs;
final int updatedAtMs;
final bool deleted;
/// ID derselben Playlist am Melo-Server, sobald sie einmal gesichert
/// wurde. `null` heißt: nur auf diesem Gerät.
final String? cloudId;
const Playlist({
required this.id,
required this.name,
@@ -1449,6 +1475,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
required this.createdAtMs,
required this.updatedAtMs,
required this.deleted,
this.cloudId,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -1461,6 +1488,9 @@ class Playlist extends DataClass implements Insertable<Playlist> {
map['created_at_ms'] = Variable<int>(createdAtMs);
map['updated_at_ms'] = Variable<int>(updatedAtMs);
map['deleted'] = Variable<bool>(deleted);
if (!nullToAbsent || cloudId != null) {
map['cloud_id'] = Variable<String>(cloudId);
}
return map;
}
@@ -1474,6 +1504,9 @@ class Playlist extends DataClass implements Insertable<Playlist> {
createdAtMs: Value(createdAtMs),
updatedAtMs: Value(updatedAtMs),
deleted: Value(deleted),
cloudId: cloudId == null && nullToAbsent
? const Value.absent()
: Value(cloudId),
);
}
@@ -1489,6 +1522,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
createdAtMs: serializer.fromJson<int>(json['createdAtMs']),
updatedAtMs: serializer.fromJson<int>(json['updatedAtMs']),
deleted: serializer.fromJson<bool>(json['deleted']),
cloudId: serializer.fromJson<String?>(json['cloudId']),
);
}
@override
@@ -1501,6 +1535,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
'createdAtMs': serializer.toJson<int>(createdAtMs),
'updatedAtMs': serializer.toJson<int>(updatedAtMs),
'deleted': serializer.toJson<bool>(deleted),
'cloudId': serializer.toJson<String?>(cloudId),
};
}
@@ -1511,6 +1546,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
int? createdAtMs,
int? updatedAtMs,
bool? deleted,
Value<String?> cloudId = const Value.absent(),
}) => Playlist(
id: id ?? this.id,
name: name ?? this.name,
@@ -1518,6 +1554,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
createdAtMs: createdAtMs ?? this.createdAtMs,
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
deleted: deleted ?? this.deleted,
cloudId: cloudId.present ? cloudId.value : this.cloudId,
);
Playlist copyWithCompanion(PlaylistsCompanion data) {
return Playlist(
@@ -1533,6 +1570,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
? data.updatedAtMs.value
: this.updatedAtMs,
deleted: data.deleted.present ? data.deleted.value : this.deleted,
cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId,
);
}
@@ -1544,14 +1582,22 @@ class Playlist extends DataClass implements Insertable<Playlist> {
..write('description: $description, ')
..write('createdAtMs: $createdAtMs, ')
..write('updatedAtMs: $updatedAtMs, ')
..write('deleted: $deleted')
..write('deleted: $deleted, ')
..write('cloudId: $cloudId')
..write(')'))
.toString();
}
@override
int get hashCode =>
Object.hash(id, name, description, createdAtMs, updatedAtMs, deleted);
int get hashCode => Object.hash(
id,
name,
description,
createdAtMs,
updatedAtMs,
deleted,
cloudId,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
@@ -1561,7 +1607,8 @@ class Playlist extends DataClass implements Insertable<Playlist> {
other.description == this.description &&
other.createdAtMs == this.createdAtMs &&
other.updatedAtMs == this.updatedAtMs &&
other.deleted == this.deleted);
other.deleted == this.deleted &&
other.cloudId == this.cloudId);
}
class PlaylistsCompanion extends UpdateCompanion<Playlist> {
@@ -1571,6 +1618,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
final Value<int> createdAtMs;
final Value<int> updatedAtMs;
final Value<bool> deleted;
final Value<String?> cloudId;
final Value<int> rowid;
const PlaylistsCompanion({
this.id = const Value.absent(),
@@ -1579,6 +1627,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
this.createdAtMs = const Value.absent(),
this.updatedAtMs = const Value.absent(),
this.deleted = const Value.absent(),
this.cloudId = const Value.absent(),
this.rowid = const Value.absent(),
});
PlaylistsCompanion.insert({
@@ -1588,6 +1637,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
required int createdAtMs,
required int updatedAtMs,
this.deleted = const Value.absent(),
this.cloudId = const Value.absent(),
this.rowid = const Value.absent(),
}) : id = Value(id),
name = Value(name),
@@ -1600,6 +1650,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
Expression<int>? createdAtMs,
Expression<int>? updatedAtMs,
Expression<bool>? deleted,
Expression<String>? cloudId,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
@@ -1609,6 +1660,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
if (createdAtMs != null) 'created_at_ms': createdAtMs,
if (updatedAtMs != null) 'updated_at_ms': updatedAtMs,
if (deleted != null) 'deleted': deleted,
if (cloudId != null) 'cloud_id': cloudId,
if (rowid != null) 'rowid': rowid,
});
}
@@ -1620,6 +1672,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
Value<int>? createdAtMs,
Value<int>? updatedAtMs,
Value<bool>? deleted,
Value<String?>? cloudId,
Value<int>? rowid,
}) {
return PlaylistsCompanion(
@@ -1629,6 +1682,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
createdAtMs: createdAtMs ?? this.createdAtMs,
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
deleted: deleted ?? this.deleted,
cloudId: cloudId ?? this.cloudId,
rowid: rowid ?? this.rowid,
);
}
@@ -1654,6 +1708,9 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
if (deleted.present) {
map['deleted'] = Variable<bool>(deleted.value);
}
if (cloudId.present) {
map['cloud_id'] = Variable<String>(cloudId.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
@@ -1669,6 +1726,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
..write('createdAtMs: $createdAtMs, ')
..write('updatedAtMs: $updatedAtMs, ')
..write('deleted: $deleted, ')
..write('cloudId: $cloudId, ')
..write('rowid: $rowid')
..write(')'))
.toString();
@@ -2767,6 +2825,440 @@ class SongCategoriesCompanion extends UpdateCompanion<SongCategory> {
}
}
class $DownloadsTable extends Downloads
with TableInfo<$DownloadsTable, Download> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$DownloadsTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _navidromeIdMeta = const VerificationMeta(
'navidromeId',
);
@override
late final GeneratedColumn<String> navidromeId = GeneratedColumn<String>(
'navidrome_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _titelMeta = const VerificationMeta('titel');
@override
late final GeneratedColumn<String> titel = GeneratedColumn<String>(
'titel',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _kuenstlerMeta = const VerificationMeta(
'kuenstler',
);
@override
late final GeneratedColumn<String> kuenstler = GeneratedColumn<String>(
'kuenstler',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _albumMeta = const VerificationMeta('album');
@override
late final GeneratedColumn<String> album = GeneratedColumn<String>(
'album',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _groesseBytesMeta = const VerificationMeta(
'groesseBytes',
);
@override
late final GeneratedColumn<int> groesseBytes = GeneratedColumn<int>(
'groesse_bytes',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
static const VerificationMeta _geladenAmMsMeta = const VerificationMeta(
'geladenAmMs',
);
@override
late final GeneratedColumn<int> geladenAmMs = GeneratedColumn<int>(
'geladen_am_ms',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [
navidromeId,
titel,
kuenstler,
album,
groesseBytes,
geladenAmMs,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'downloads';
@override
VerificationContext validateIntegrity(
Insertable<Download> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('navidrome_id')) {
context.handle(
_navidromeIdMeta,
navidromeId.isAcceptableOrUnknown(
data['navidrome_id']!,
_navidromeIdMeta,
),
);
} else if (isInserting) {
context.missing(_navidromeIdMeta);
}
if (data.containsKey('titel')) {
context.handle(
_titelMeta,
titel.isAcceptableOrUnknown(data['titel']!, _titelMeta),
);
} else if (isInserting) {
context.missing(_titelMeta);
}
if (data.containsKey('kuenstler')) {
context.handle(
_kuenstlerMeta,
kuenstler.isAcceptableOrUnknown(data['kuenstler']!, _kuenstlerMeta),
);
}
if (data.containsKey('album')) {
context.handle(
_albumMeta,
album.isAcceptableOrUnknown(data['album']!, _albumMeta),
);
}
if (data.containsKey('groesse_bytes')) {
context.handle(
_groesseBytesMeta,
groesseBytes.isAcceptableOrUnknown(
data['groesse_bytes']!,
_groesseBytesMeta,
),
);
} else if (isInserting) {
context.missing(_groesseBytesMeta);
}
if (data.containsKey('geladen_am_ms')) {
context.handle(
_geladenAmMsMeta,
geladenAmMs.isAcceptableOrUnknown(
data['geladen_am_ms']!,
_geladenAmMsMeta,
),
);
} else if (isInserting) {
context.missing(_geladenAmMsMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {navidromeId};
@override
Download map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return Download(
navidromeId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}navidrome_id'],
)!,
titel: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}titel'],
)!,
kuenstler: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}kuenstler'],
),
album: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}album'],
),
groesseBytes: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}groesse_bytes'],
)!,
geladenAmMs: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}geladen_am_ms'],
)!,
);
}
@override
$DownloadsTable createAlias(String alias) {
return $DownloadsTable(attachedDatabase, alias);
}
}
class Download extends DataClass implements Insertable<Download> {
final String navidromeId;
final String titel;
final String? kuenstler;
final String? album;
final int groesseBytes;
final int geladenAmMs;
const Download({
required this.navidromeId,
required this.titel,
this.kuenstler,
this.album,
required this.groesseBytes,
required this.geladenAmMs,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['navidrome_id'] = Variable<String>(navidromeId);
map['titel'] = Variable<String>(titel);
if (!nullToAbsent || kuenstler != null) {
map['kuenstler'] = Variable<String>(kuenstler);
}
if (!nullToAbsent || album != null) {
map['album'] = Variable<String>(album);
}
map['groesse_bytes'] = Variable<int>(groesseBytes);
map['geladen_am_ms'] = Variable<int>(geladenAmMs);
return map;
}
DownloadsCompanion toCompanion(bool nullToAbsent) {
return DownloadsCompanion(
navidromeId: Value(navidromeId),
titel: Value(titel),
kuenstler: kuenstler == null && nullToAbsent
? const Value.absent()
: Value(kuenstler),
album: album == null && nullToAbsent
? const Value.absent()
: Value(album),
groesseBytes: Value(groesseBytes),
geladenAmMs: Value(geladenAmMs),
);
}
factory Download.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return Download(
navidromeId: serializer.fromJson<String>(json['navidromeId']),
titel: serializer.fromJson<String>(json['titel']),
kuenstler: serializer.fromJson<String?>(json['kuenstler']),
album: serializer.fromJson<String?>(json['album']),
groesseBytes: serializer.fromJson<int>(json['groesseBytes']),
geladenAmMs: serializer.fromJson<int>(json['geladenAmMs']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'navidromeId': serializer.toJson<String>(navidromeId),
'titel': serializer.toJson<String>(titel),
'kuenstler': serializer.toJson<String?>(kuenstler),
'album': serializer.toJson<String?>(album),
'groesseBytes': serializer.toJson<int>(groesseBytes),
'geladenAmMs': serializer.toJson<int>(geladenAmMs),
};
}
Download copyWith({
String? navidromeId,
String? titel,
Value<String?> kuenstler = const Value.absent(),
Value<String?> album = const Value.absent(),
int? groesseBytes,
int? geladenAmMs,
}) => Download(
navidromeId: navidromeId ?? this.navidromeId,
titel: titel ?? this.titel,
kuenstler: kuenstler.present ? kuenstler.value : this.kuenstler,
album: album.present ? album.value : this.album,
groesseBytes: groesseBytes ?? this.groesseBytes,
geladenAmMs: geladenAmMs ?? this.geladenAmMs,
);
Download copyWithCompanion(DownloadsCompanion data) {
return Download(
navidromeId: data.navidromeId.present
? data.navidromeId.value
: this.navidromeId,
titel: data.titel.present ? data.titel.value : this.titel,
kuenstler: data.kuenstler.present ? data.kuenstler.value : this.kuenstler,
album: data.album.present ? data.album.value : this.album,
groesseBytes: data.groesseBytes.present
? data.groesseBytes.value
: this.groesseBytes,
geladenAmMs: data.geladenAmMs.present
? data.geladenAmMs.value
: this.geladenAmMs,
);
}
@override
String toString() {
return (StringBuffer('Download(')
..write('navidromeId: $navidromeId, ')
..write('titel: $titel, ')
..write('kuenstler: $kuenstler, ')
..write('album: $album, ')
..write('groesseBytes: $groesseBytes, ')
..write('geladenAmMs: $geladenAmMs')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
navidromeId,
titel,
kuenstler,
album,
groesseBytes,
geladenAmMs,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is Download &&
other.navidromeId == this.navidromeId &&
other.titel == this.titel &&
other.kuenstler == this.kuenstler &&
other.album == this.album &&
other.groesseBytes == this.groesseBytes &&
other.geladenAmMs == this.geladenAmMs);
}
class DownloadsCompanion extends UpdateCompanion<Download> {
final Value<String> navidromeId;
final Value<String> titel;
final Value<String?> kuenstler;
final Value<String?> album;
final Value<int> groesseBytes;
final Value<int> geladenAmMs;
final Value<int> rowid;
const DownloadsCompanion({
this.navidromeId = const Value.absent(),
this.titel = const Value.absent(),
this.kuenstler = const Value.absent(),
this.album = const Value.absent(),
this.groesseBytes = const Value.absent(),
this.geladenAmMs = const Value.absent(),
this.rowid = const Value.absent(),
});
DownloadsCompanion.insert({
required String navidromeId,
required String titel,
this.kuenstler = const Value.absent(),
this.album = const Value.absent(),
required int groesseBytes,
required int geladenAmMs,
this.rowid = const Value.absent(),
}) : navidromeId = Value(navidromeId),
titel = Value(titel),
groesseBytes = Value(groesseBytes),
geladenAmMs = Value(geladenAmMs);
static Insertable<Download> custom({
Expression<String>? navidromeId,
Expression<String>? titel,
Expression<String>? kuenstler,
Expression<String>? album,
Expression<int>? groesseBytes,
Expression<int>? geladenAmMs,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (navidromeId != null) 'navidrome_id': navidromeId,
if (titel != null) 'titel': titel,
if (kuenstler != null) 'kuenstler': kuenstler,
if (album != null) 'album': album,
if (groesseBytes != null) 'groesse_bytes': groesseBytes,
if (geladenAmMs != null) 'geladen_am_ms': geladenAmMs,
if (rowid != null) 'rowid': rowid,
});
}
DownloadsCompanion copyWith({
Value<String>? navidromeId,
Value<String>? titel,
Value<String?>? kuenstler,
Value<String?>? album,
Value<int>? groesseBytes,
Value<int>? geladenAmMs,
Value<int>? rowid,
}) {
return DownloadsCompanion(
navidromeId: navidromeId ?? this.navidromeId,
titel: titel ?? this.titel,
kuenstler: kuenstler ?? this.kuenstler,
album: album ?? this.album,
groesseBytes: groesseBytes ?? this.groesseBytes,
geladenAmMs: geladenAmMs ?? this.geladenAmMs,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (navidromeId.present) {
map['navidrome_id'] = Variable<String>(navidromeId.value);
}
if (titel.present) {
map['titel'] = Variable<String>(titel.value);
}
if (kuenstler.present) {
map['kuenstler'] = Variable<String>(kuenstler.value);
}
if (album.present) {
map['album'] = Variable<String>(album.value);
}
if (groesseBytes.present) {
map['groesse_bytes'] = Variable<int>(groesseBytes.value);
}
if (geladenAmMs.present) {
map['geladen_am_ms'] = Variable<int>(geladenAmMs.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('DownloadsCompanion(')
..write('navidromeId: $navidromeId, ')
..write('titel: $titel, ')
..write('kuenstler: $kuenstler, ')
..write('album: $album, ')
..write('groesseBytes: $groesseBytes, ')
..write('geladenAmMs: $geladenAmMs, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$MeloDb extends GeneratedDatabase {
_$MeloDb(QueryExecutor e) : super(e);
$MeloDbManager get managers => $MeloDbManager(this);
@@ -2779,6 +3271,7 @@ abstract class _$MeloDb extends GeneratedDatabase {
this,
);
late final $SongCategoriesTable songCategories = $SongCategoriesTable(this);
late final $DownloadsTable downloads = $DownloadsTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@@ -2791,6 +3284,7 @@ abstract class _$MeloDb extends GeneratedDatabase {
favorites,
playbackHistory,
songCategories,
downloads,
];
}
@@ -3780,6 +4274,7 @@ typedef $$PlaylistsTableCreateCompanionBuilder =
required int createdAtMs,
required int updatedAtMs,
Value<bool> deleted,
Value<String?> cloudId,
Value<int> rowid,
});
typedef $$PlaylistsTableUpdateCompanionBuilder =
@@ -3790,6 +4285,7 @@ typedef $$PlaylistsTableUpdateCompanionBuilder =
Value<int> createdAtMs,
Value<int> updatedAtMs,
Value<bool> deleted,
Value<String?> cloudId,
Value<int> rowid,
});
@@ -3855,6 +4351,11 @@ class $$PlaylistsTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get cloudId => $composableBuilder(
column: $table.cloudId,
builder: (column) => ColumnFilters(column),
);
Expression<bool> playlistSongsRefs(
Expression<bool> Function($$PlaylistSongsTableFilterComposer f) f,
) {
@@ -3919,6 +4420,11 @@ class $$PlaylistsTableOrderingComposer
column: $table.deleted,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get cloudId => $composableBuilder(
column: $table.cloudId,
builder: (column) => ColumnOrderings(column),
);
}
class $$PlaylistsTableAnnotationComposer
@@ -3954,6 +4460,9 @@ class $$PlaylistsTableAnnotationComposer
GeneratedColumn<bool> get deleted =>
$composableBuilder(column: $table.deleted, builder: (column) => column);
GeneratedColumn<String> get cloudId =>
$composableBuilder(column: $table.cloudId, builder: (column) => column);
Expression<T> playlistSongsRefs<T extends Object>(
Expression<T> Function($$PlaylistSongsTableAnnotationComposer a) f,
) {
@@ -4014,6 +4523,7 @@ class $$PlaylistsTableTableManager
Value<int> createdAtMs = const Value.absent(),
Value<int> updatedAtMs = const Value.absent(),
Value<bool> deleted = const Value.absent(),
Value<String?> cloudId = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => PlaylistsCompanion(
id: id,
@@ -4022,6 +4532,7 @@ class $$PlaylistsTableTableManager
createdAtMs: createdAtMs,
updatedAtMs: updatedAtMs,
deleted: deleted,
cloudId: cloudId,
rowid: rowid,
),
createCompanionCallback:
@@ -4032,6 +4543,7 @@ class $$PlaylistsTableTableManager
required int createdAtMs,
required int updatedAtMs,
Value<bool> deleted = const Value.absent(),
Value<String?> cloudId = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => PlaylistsCompanion.insert(
id: id,
@@ -4040,6 +4552,7 @@ class $$PlaylistsTableTableManager
createdAtMs: createdAtMs,
updatedAtMs: updatedAtMs,
deleted: deleted,
cloudId: cloudId,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
@@ -5324,6 +5837,225 @@ typedef $$SongCategoriesTableProcessedTableManager =
SongCategory,
PrefetchHooks Function({bool songId})
>;
typedef $$DownloadsTableCreateCompanionBuilder =
DownloadsCompanion Function({
required String navidromeId,
required String titel,
Value<String?> kuenstler,
Value<String?> album,
required int groesseBytes,
required int geladenAmMs,
Value<int> rowid,
});
typedef $$DownloadsTableUpdateCompanionBuilder =
DownloadsCompanion Function({
Value<String> navidromeId,
Value<String> titel,
Value<String?> kuenstler,
Value<String?> album,
Value<int> groesseBytes,
Value<int> geladenAmMs,
Value<int> rowid,
});
class $$DownloadsTableFilterComposer
extends Composer<_$MeloDb, $DownloadsTable> {
$$DownloadsTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get navidromeId => $composableBuilder(
column: $table.navidromeId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get titel => $composableBuilder(
column: $table.titel,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get kuenstler => $composableBuilder(
column: $table.kuenstler,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get album => $composableBuilder(
column: $table.album,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get groesseBytes => $composableBuilder(
column: $table.groesseBytes,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get geladenAmMs => $composableBuilder(
column: $table.geladenAmMs,
builder: (column) => ColumnFilters(column),
);
}
class $$DownloadsTableOrderingComposer
extends Composer<_$MeloDb, $DownloadsTable> {
$$DownloadsTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get navidromeId => $composableBuilder(
column: $table.navidromeId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get titel => $composableBuilder(
column: $table.titel,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get kuenstler => $composableBuilder(
column: $table.kuenstler,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get album => $composableBuilder(
column: $table.album,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get groesseBytes => $composableBuilder(
column: $table.groesseBytes,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get geladenAmMs => $composableBuilder(
column: $table.geladenAmMs,
builder: (column) => ColumnOrderings(column),
);
}
class $$DownloadsTableAnnotationComposer
extends Composer<_$MeloDb, $DownloadsTable> {
$$DownloadsTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get navidromeId => $composableBuilder(
column: $table.navidromeId,
builder: (column) => column,
);
GeneratedColumn<String> get titel =>
$composableBuilder(column: $table.titel, builder: (column) => column);
GeneratedColumn<String> get kuenstler =>
$composableBuilder(column: $table.kuenstler, builder: (column) => column);
GeneratedColumn<String> get album =>
$composableBuilder(column: $table.album, builder: (column) => column);
GeneratedColumn<int> get groesseBytes => $composableBuilder(
column: $table.groesseBytes,
builder: (column) => column,
);
GeneratedColumn<int> get geladenAmMs => $composableBuilder(
column: $table.geladenAmMs,
builder: (column) => column,
);
}
class $$DownloadsTableTableManager
extends
RootTableManager<
_$MeloDb,
$DownloadsTable,
Download,
$$DownloadsTableFilterComposer,
$$DownloadsTableOrderingComposer,
$$DownloadsTableAnnotationComposer,
$$DownloadsTableCreateCompanionBuilder,
$$DownloadsTableUpdateCompanionBuilder,
(Download, BaseReferences<_$MeloDb, $DownloadsTable, Download>),
Download,
PrefetchHooks Function()
> {
$$DownloadsTableTableManager(_$MeloDb db, $DownloadsTable table)
: super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$DownloadsTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$DownloadsTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$DownloadsTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<String> navidromeId = const Value.absent(),
Value<String> titel = const Value.absent(),
Value<String?> kuenstler = const Value.absent(),
Value<String?> album = const Value.absent(),
Value<int> groesseBytes = const Value.absent(),
Value<int> geladenAmMs = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => DownloadsCompanion(
navidromeId: navidromeId,
titel: titel,
kuenstler: kuenstler,
album: album,
groesseBytes: groesseBytes,
geladenAmMs: geladenAmMs,
rowid: rowid,
),
createCompanionCallback:
({
required String navidromeId,
required String titel,
Value<String?> kuenstler = const Value.absent(),
Value<String?> album = const Value.absent(),
required int groesseBytes,
required int geladenAmMs,
Value<int> rowid = const Value.absent(),
}) => DownloadsCompanion.insert(
navidromeId: navidromeId,
titel: titel,
kuenstler: kuenstler,
album: album,
groesseBytes: groesseBytes,
geladenAmMs: geladenAmMs,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$DownloadsTableProcessedTableManager =
ProcessedTableManager<
_$MeloDb,
$DownloadsTable,
Download,
$$DownloadsTableFilterComposer,
$$DownloadsTableOrderingComposer,
$$DownloadsTableAnnotationComposer,
$$DownloadsTableCreateCompanionBuilder,
$$DownloadsTableUpdateCompanionBuilder,
(Download, BaseReferences<_$MeloDb, $DownloadsTable, Download>),
Download,
PrefetchHooks Function()
>;
class $MeloDbManager {
final _$MeloDb _db;
@@ -5342,4 +6074,6 @@ class $MeloDbManager {
$$PlaybackHistoryTableTableManager(_db, _db.playbackHistory);
$$SongCategoriesTableTableManager get songCategories =>
$$SongCategoriesTableTableManager(_db, _db.songCategories);
$$DownloadsTableTableManager get downloads =>
$$DownloadsTableTableManager(_db, _db.downloads);
}
+3 -8
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../shared/sort_store.dart';
import '../shared/sortable_song_list.dart';
import '../shared/ueberschrift.dart';
import 'database.dart';
import '../shared/theme.dart';
@@ -18,14 +19,7 @@ class FavoritesScreen extends StatelessWidget {
bottom: false,
child: Column(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('Favoriten',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
),
),
const Ueberschrift('Favoriten'),
Expanded(
child: StreamBuilder<List<Song>>(
stream: db.watchFavorites(),
@@ -65,3 +59,4 @@ class _Empty extends StatelessWidget {
);
}
}
+20 -2
View File
@@ -25,8 +25,23 @@ class LibraryService extends ChangeNotifier {
int scanDone = 0;
int scanTotal = 0;
bool permissionDenied = false;
bool notificationPermissionDenied = false;
String? scanError;
/// Fragt beim ersten Start alle für den Betrieb nötigen Berechtigungen an
/// (Musik-Zugriff + Wiedergabe-Benachrichtigung), statt sie erst bei Bedarf
/// einzeln einzufordern.
///
/// Nacheinander, nicht gleichzeitig: `permission_handler` wirft, wenn eine
/// zweite Abfrage startet, während die erste noch offen ist.
Future<void> ensureStartupPermissions() async {
await _ensurePermission();
final denied = !await ensureNotificationPermission();
if (denied == notificationPermissionDenied) return;
notificationPermissionDenied = denied;
notifyListeners();
}
/// Auf Android: automatischer Geräte-Scan. Auf Desktop: Ordner wählen + scannen.
Future<void> pickFolderAndScan() async {
if (!await _ensurePermission()) return;
@@ -52,8 +67,11 @@ class LibraryService extends ChangeNotifier {
Future<bool> _ensurePermission() async {
final granted = await ensureAudioPermission();
permissionDenied = !granted;
notifyListeners();
final denied = !granted;
if (denied != permissionDenied) {
permissionDenied = denied;
notifyListeners();
}
return granted;
}
+65 -43
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../playlists/playlists_screen.dart';
import '../settings/app_settings.dart';
import '../settings/settings_screen.dart';
import '../shared/cover.dart';
import '../shared/sort_store.dart';
@@ -57,6 +58,19 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
),
],
),
if (lib.notificationPermissionDenied)
MaterialBanner(
backgroundColor: Colors.red.shade900,
content: const Text(
'Benachrichtigungen für die Wiedergabe sind deaktiviert. '
'Bitte in den Einstellungen erlauben.'),
actions: [
TextButton(
onPressed: openMusicPermissionSettings,
child: const Text('Einstellungen'),
),
],
),
if (lib.scanError != null)
MaterialBanner(
backgroundColor: Colors.red.shade900,
@@ -106,6 +120,7 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
return SortableSongList(
songs: songs,
storeKey: SortStore.meineMusik,
serverUpload: true,
empty:
lib.scanning ? const SizedBox.shrink() : const _Empty(),
);
@@ -163,7 +178,7 @@ class _Header extends StatelessWidget {
),
IconButton(
tooltip: 'Musik erkennen',
icon: const Icon(Icons.help_outline),
icon: const Icon(Icons.mic_none),
onPressed: () => MusicRecognitionSheet.show(context),
),
],
@@ -180,54 +195,61 @@ class _QuickAccessRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
final sektionen = context.watch<AppSettings>().meineMusikSektionen;
if (sektionen.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: 104,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
StreamBuilder<List<Song>>(
stream: db.watchFavorites(),
builder: (context, snapshot) {
final favorites = snapshot.data ?? const <Song>[];
return _QuickCard(
icon: Icons.favorite,
label: 'Favoriten',
subtitle: '${favorites.length} Songs',
coverPath: favorites.isEmpty ? null : favorites.first.coverPath,
onTap: onFavoritesTap,
);
},
),
StreamBuilder<List<Playlist>>(
stream: db.watchPlaylists(),
builder: (context, snapshot) {
final playlists = snapshot.data ?? const <Playlist>[];
return _QuickCard(
icon: Icons.queue_music,
label: 'Wiedergabelisten',
subtitle: '${playlists.length} Listen',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const PlaylistsScreen()),
),
);
},
),
StreamBuilder<List<Song>>(
stream: db.watchRecent(limit: 50),
builder: (context, snapshot) {
final recent = snapshot.data ?? const <Song>[];
return _QuickCard(
icon: Icons.schedule,
label: 'Zuletzt',
subtitle: 'Neu hinzugefügt',
coverPath: recent.isEmpty ? null : recent.first.coverPath,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const RecentlyAddedScreen()),
),
);
},
),
if (sektionen.contains(AppSettings.sektionFavoriten))
StreamBuilder<List<Song>>(
stream: db.watchFavorites(),
builder: (context, snapshot) {
final favorites = snapshot.data ?? const <Song>[];
return _QuickCard(
icon: Icons.favorite,
label: 'Favoriten',
subtitle: '${favorites.length} Songs',
coverPath:
favorites.isEmpty ? null : favorites.first.coverPath,
onTap: onFavoritesTap,
);
},
),
if (sektionen.contains(AppSettings.sektionWiedergabelisten))
StreamBuilder<List<Playlist>>(
stream: db.watchPlaylists(),
builder: (context, snapshot) {
final playlists = snapshot.data ?? const <Playlist>[];
return _QuickCard(
icon: Icons.queue_music,
label: 'Wiedergabelisten',
subtitle: '${playlists.length} Listen',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const PlaylistsScreen()),
),
);
},
),
if (sektionen.contains(AppSettings.sektionZuletzt))
StreamBuilder<List<Song>>(
stream: db.watchRecent(limit: 50),
builder: (context, snapshot) {
final recent = snapshot.data ?? const <Song>[];
return _QuickCard(
icon: Icons.schedule,
label: 'Zuletzt',
subtitle: 'Neu hinzugefügt',
coverPath: recent.isEmpty ? null : recent.first.coverPath,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const RecentlyAddedScreen()),
),
);
},
),
],
),
);
+27
View File
@@ -19,3 +19,30 @@ Future<bool> ensureAudioPermission() async {
/// Öffnet die System-Einstellungen der App (für dauerhaft verweigerte Rechte).
Future<void> openMusicPermissionSettings() => openAppSettings();
/// Ob für die Wiedergabe-Benachrichtigung eine Laufzeit-Berechtigung nötig ist.
/// Erst ab Android 13 (API 33) gibt es POST_NOTIFICATIONS; davor erscheint die
/// Benachrichtigung ohne Nachfrage, und ein Antrag liefe ins Leere.
bool brauchtBenachrichtigungsRecht({
required bool istAndroid,
required int sdkInt,
}) =>
istAndroid && sdkInt >= 33;
/// Fragt das Recht an, die Wiedergabe-Benachrichtigung zu zeigen.
///
/// Ohne dieses Recht läuft die Wiedergabe auf Android 13+ zwar weiter, aber
/// Benachrichtigung und die Bedienknöpfe darin fehlen vollständig — die
/// Steuerung ist dann nur noch in der geöffneten App erreichbar.
Future<bool> ensureNotificationPermission() async {
if (!Platform.isAndroid) return true;
final info = await DeviceInfoPlugin().androidInfo;
if (!brauchtBenachrichtigungsRecht(
istAndroid: true,
sdkInt: info.version.sdkInt,
)) {
return true;
}
final status = await Permission.notification.request();
return status.isGranted;
}
+121 -6
View File
@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart';
import '../services/melo_cloud_service.dart';
import '../services/navidrome_service.dart';
import 'database.dart';
@@ -6,14 +7,22 @@ import 'database.dart';
/// nach jeder Mutation (für Feedback wie SnackBars — die Listen selbst
/// beobachten UIs direkt über die watch()-Streams von [MeloDb]).
class PlaylistService extends ChangeNotifier {
PlaylistService(this.db, {NavidromeService? navidrome})
: _navidrome = navidrome ?? NavidromeService();
// `this._cloud` würde den öffentlichen Parameternamen `cloud` zu `_cloud`
// (privat) ändern und die API brechen.
PlaylistService(this.db, {NavidromeService? navidrome, MeloCloudService? cloud})
: _navidrome = navidrome ?? NavidromeService(),
// ignore: prefer_initializing_formals
_cloud = cloud;
final MeloDb db;
final NavidromeService _navidrome;
/// Optional: ohne Cloud-Zugang entfällt der Sofort-Push stillschweigend.
final MeloCloudService? _cloud;
Future<String> createPlaylist(String name, {String? description}) async {
final id = await db.createPlaylist(name, description: description);
notifyListeners();
await _sichereNeuePlaylist(id, name);
return id;
}
@@ -22,29 +31,64 @@ class PlaylistService extends ChangeNotifier {
notifyListeners();
}
Future<void> addSongToPlaylist(String playlistId, String songId, int position) async {
Future<void> addSongToPlaylist(
String playlistId, String songId, int position) async {
await db.addSongToPlaylist(playlistId, songId, position);
notifyListeners();
final cloudId = await _cloudIdDerPlaylist(playlistId);
final songCloudId = (await db.songById(songId))?.cloudId;
if (cloudId == null || songCloudId == null) return;
await _still(() =>
_cloud!.fuegePlaylistSongsHinzu(cloudId, [songCloudId]));
}
Future<void> removeSongFromPlaylist(String playlistId, String songId) async {
Future<void> removeSongFromPlaylist(
String playlistId, String songId) async {
final songCloudId = (await db.songById(songId))?.cloudId;
await db.removeSongFromPlaylist(playlistId, songId);
notifyListeners();
final cloudId = await _cloudIdDerPlaylist(playlistId);
if (cloudId == null || songCloudId == null) return;
await _still(() => _cloud!.entfernePlaylistSong(cloudId, songCloudId));
}
Future<void> reorderSong(String playlistId, String songId, int newPosition) async {
Future<void> reorderSong(
String playlistId, String songId, int newPosition) async {
await db.reorderPlaylistSong(playlistId, songId, newPosition);
notifyListeners();
await _sichereReihenfolge(playlistId);
}
Future<void> reorderAll(String playlistId, List<String> orderedSongIds) async {
Future<void> reorderAll(
String playlistId, List<String> orderedSongIds) async {
await db.reorderAllPlaylistSongs(playlistId, orderedSongIds);
notifyListeners();
await _sichereReihenfolge(playlistId);
}
Future<void> toggleFavorite(String songId) async {
await db.toggleFavorite(songId);
notifyListeners();
await _meldeFavorit(songId);
}
/// Meldet den neuen Favoriten-Stand sofort an die Melo-Cloud.
///
/// Deterministisch (`set:true/false`), nicht als Umschalten: ein
/// abweichender Server-Stand darf den Wunsch nicht invertieren. Nur für
/// Titel mit cloudId, und Fehler werden still geschluckt — der nächste
/// Abgleich pusht additiv nach.
Future<void> _meldeFavorit(String songId) async {
final cloud = _cloud;
if (cloud == null || !cloud.istAngemeldet) return;
final cloudId = (await db.songById(songId))?.cloudId;
if (cloudId == null) return;
final gesetzt = await db.watchIsFavorite(songId).first;
try {
await cloud.setzeFavorit(cloudId, gesetzt);
} catch (e) {
debugPrint('Favorit nicht gemeldet: $e');
}
}
/// Lädt alle Favoriten vom Navidrome-Server und importiert sie lokal.
@@ -107,4 +151,75 @@ class PlaylistService extends ChangeNotifier {
return 0;
}
}
/// Führt [aktion] aus und verwirft jeden Fehler.
///
/// Die Sicherung ist einseitig und ohne Rollback: schlägt sie fehl, bleibt
/// der lokale Stand, wie er ist, und die nächste Änderung versucht es
/// erneut.
Future<void> _still(Future<void> Function() aktion) async {
try {
await aktion();
} catch (e) {
debugPrint('Playlist-Sicherung übersprungen: $e');
}
}
Future<String?> _cloudIdDerPlaylist(String playlistId) async {
if (_cloud == null || !_cloud.istAngemeldet) return null;
return (await db.playlistById(playlistId))?.cloudId;
}
Future<void> _sichereNeuePlaylist(String id, String name) async {
final cloud = _cloud;
if (cloud == null || !cloud.istAngemeldet) return;
await _still(() async {
final cloudId = await cloud.legePlaylistAn(name);
await db.setPlaylistCloudId(id, cloudId);
});
}
Future<void> _sichereReihenfolge(String playlistId) async {
final cloudId = await _cloudIdDerPlaylist(playlistId);
if (cloudId == null) return;
final songs = await db.watchPlaylistSongs(playlistId).first;
final ids = [
for (final s in songs)
if (s.cloudId != null) s.cloudId!,
];
if (ids.isEmpty) return;
await _still(() => _cloud!.setzePlaylistReihenfolge(cloudId, ids));
}
/// Holt die Playlisten des Servers **nur** auf ein Gerät ohne eigene:
/// Neuinstallation oder Wiederherstellung.
///
/// Es gibt bewusst keinen Rück-Merge — Umbenennungen und Änderungen eines
/// zweiten Geräts erscheinen hier nicht. Der beidseitige Abgleich ist eine
/// eigene, spätere Spec.
Future<int> stelleWiederHer() async {
final cloud = _cloud;
if (cloud == null || !cloud.istAngemeldet) return 0;
if (await db.countPlaylists() > 0) return 0;
var angelegt = 0;
try {
for (final vomServer in await cloud.playlisten()) {
final id = await db.createPlaylist(vomServer.name);
await db.setPlaylistCloudId(id, vomServer.id);
final songCloudIds = await cloud.playlistSongs(vomServer.id);
var position = 0;
for (final songCloudId in songCloudIds) {
final song = await db.songByCloudId(songCloudId);
if (song == null) continue;
await db.addSongToPlaylist(id, song.id, position++);
}
angelegt++;
}
} catch (e) {
debugPrint('Playlist-Wiederherstellung abgebrochen: $e');
}
notifyListeners();
return angelegt;
}
}
+412 -36
View File
@@ -1,10 +1,31 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../playlists/playlist_detail_screen.dart';
import '../shared/theme.dart';
import '../shared/ueberschrift.dart';
import '../shared/titel_listen_screen.dart';
import 'category_service.dart';
import 'database.dart';
import 'song_list.dart';
import '../shared/theme.dart';
import 'suche.dart';
import 'such_verlauf.dart';
/// Wie lange nach dem letzten Tastendruck gewartet wird, bevor gesucht wird.
/// Ohne diese Pause baut jeder Buchstabe die ganze Trefferliste neu auf.
const _entprellung = Duration(milliseconds: 250);
/// Wie viele Einträge ein Abschnitt höchstens zeigt.
///
/// Gilt für alle vier: eine kurze Eingabe wie „a" trifft in einer großen
/// Bibliothek leicht hunderte Künstler, und die würden sonst alle bei jedem
/// Neuzeichnen gebaut. Die Gesamtzahl steht in der Überschrift, und die
/// Sortierung stellt die passendsten nach vorn.
const _vorschau = 5;
/// Suchen-Tab: findet in der Bibliothek und trennt die Treffer nach Art.
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@@ -13,47 +34,402 @@ class SearchScreen extends StatefulWidget {
}
class _SearchScreenState extends State<SearchScreen> {
final _feld = TextEditingController();
Timer? _entprellen;
// Einmal anlegen, nicht in jedem build: der Bildschirm hängt über
// CategoryService auch an jedem Scan-Fortschritt, und ein neuer Stream je
// Rebuild würde die Abfrage laufend neu ausführen.
late final Stream<List<Song>> _songs = context.read<MeloDb>().watchSongs();
late final Stream<List<Playlist>> _listen =
context.read<MeloDb>().watchPlaylists();
String _query = '';
List<String> _verlauf = const [];
@override
void initState() {
super.initState();
SuchVerlauf.laden().then((v) {
if (mounted) setState(() => _verlauf = v);
});
}
@override
void dispose() {
_entprellen?.cancel();
_feld.dispose();
super.dispose();
}
void _tippt(String wert) {
_entprellen?.cancel();
_entprellen = Timer(_entprellung, () {
if (mounted) setState(() => _query = wert.trim());
});
}
/// Merkt den Begriff, wenn der Nutzer die Suche abschickt.
///
/// Bewusst nicht beim Tippen und erst recht nicht beim Aufbau der Treffer:
/// von dort aus würde jeder Rebuild schreiben und über `setState` den
/// nächsten Rebuild auslösen. Abschicken ist außerdem das ehrlichere
/// Signal — halbe Wörter beim Tippen sind keine Suche, die man wiederholen
/// möchte.
Future<void> _merke(String begriff) async {
if (begriff.trim().isEmpty) return;
final neu = await SuchVerlauf.merke(begriff);
if (mounted) setState(() => _verlauf = neu);
}
void _uebernimm(String begriff) {
_entprellen?.cancel();
_feld.text = begriff;
setState(() => _query = begriff);
}
Future<void> _verlaufLeeren() async {
await SuchVerlauf.leeren();
if (mounted) setState(() => _verlauf = const []);
}
Future<void> _verlaufEntferne(String begriff) async {
final neu = await SuchVerlauf.entferne(begriff);
if (mounted) setState(() => _verlauf = neu);
}
void _leeren() {
_entprellen?.cancel();
_feld.clear();
setState(() => _query = '');
}
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return Scaffold(
appBar: AppBar(
title: TextField(
autofocus: false,
decoration: const InputDecoration(
hintText: 'Titel, Künstler, Album …',
border: InputBorder.none,
prefixIcon: Icon(Icons.search),
final kategorien = context.watch<CategoryService>().alleNamen;
return SafeArea(
bottom: false,
child: Column(
children: [
const Ueberschrift('Suchen'),
_Suchfeld(
controller: _feld,
onChanged: _tippt,
onAbschicken: _merke,
onLeeren: _leeren,
),
onChanged: (v) => setState(() => _query = v.trim()),
),
Expanded(
child: _query.isEmpty
? _Verlauf(
begriffe: _verlauf,
onWaehlen: _uebernimm,
onLeeren: _verlaufLeeren,
onEntfernen: _verlaufEntferne,
)
: StreamBuilder<List<Song>>(
stream: _songs,
builder: (context, songSnap) {
if (songSnap.hasError) {
return _Hinweis('Fehler: ${songSnap.error}');
}
return StreamBuilder<List<Playlist>>(
stream: _listen,
builder: (context, listenSnap) {
if (listenSnap.hasError) {
return _Hinweis('Fehler: ${listenSnap.error}');
}
final treffer = sucheLokal(
songs: songSnap.data ?? const [],
kategorien: kategorien,
playlisten: listenSnap.data ?? const [],
query: _query,
);
if (treffer.istLeer) {
return _Hinweis('Nichts gefunden für „$_query"');
}
return _Ergebnisse(
treffer: treffer,
alleSongs: songSnap.data ?? const [],
);
},
);
},
),
),
],
),
body: _query.isEmpty
? const Center(
child: Text('Suchbegriff eingeben',
style: TextStyle(color: MeloTheme.text2)),
)
: StreamBuilder<List<Song>>(
stream: db.searchSongs(_query),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: MeloTheme.text2)),
);
}
final songs = snapshot.data ?? const [];
if (songs.isEmpty) {
return const Center(
child: Text('Nichts gefunden',
style: TextStyle(color: MeloTheme.text2)),
);
}
return SongList(songs);
},
),
);
}
}
class _Suchfeld extends StatelessWidget {
const _Suchfeld({
required this.controller,
required this.onChanged,
required this.onAbschicken,
required this.onLeeren,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
final ValueChanged<String> onAbschicken;
final VoidCallback onLeeren;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.sm, MeloSpace.md, MeloSpace.sm),
child: Container(
height: 44,
decoration: BoxDecoration(
color: MeloTheme.surfaceHigh,
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
const SizedBox(width: MeloSpace.md - 2),
const Icon(Icons.search, color: MeloTheme.text2, size: 20),
const SizedBox(width: MeloSpace.sm + 2),
Expanded(
child: TextField(
controller: controller,
onChanged: onChanged,
onSubmitted: onAbschicken,
textInputAction: TextInputAction.search,
style: const TextStyle(fontSize: 14, color: MeloTheme.text1),
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: 'Titel, Künstler, Kategorien und Listen',
hintStyle: TextStyle(color: MeloTheme.text2, fontSize: 14),
),
),
),
// Feste Breite und direkt am Controller: sonst erschiene das X
// erst nach der Entprellung — und würde das Textfeld dabei um
// die Knopfbreite enger springen lassen.
SizedBox(
width: MeloTheme.minTouchTarget,
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, wert, _) => wert.text.isEmpty
? const SizedBox.shrink()
: IconButton(
tooltip: 'Eingabe löschen',
icon: const Icon(Icons.close, size: 20),
onPressed: onLeeren,
),
),
),
],
),
),
);
}
}
/// Letzte Suchbegriffe, solange das Feld leer ist.
class _Verlauf extends StatelessWidget {
const _Verlauf({
required this.begriffe,
required this.onWaehlen,
required this.onLeeren,
required this.onEntfernen,
});
final List<String> begriffe;
final ValueChanged<String> onWaehlen;
final VoidCallback onLeeren;
final ValueChanged<String> onEntfernen;
@override
Widget build(BuildContext context) {
if (begriffe.isEmpty) {
return const _Hinweis('Suchbegriff eingeben');
}
return ListView(
children: [
Row(
children: [
const Expanded(child: _AbschnittsKopf(label: 'Zuletzt gesucht')),
TextButton(onPressed: onLeeren, child: const Text('Löschen')),
const SizedBox(width: MeloSpace.sm),
],
),
for (final begriff in begriffe)
ListTile(
leading: const Icon(Icons.history),
title: Text(begriff),
trailing: IconButton(
tooltip: '$begriff" aus dem Verlauf entfernen',
icon: const Icon(Icons.close, size: 18),
onPressed: () => onEntfernen(begriff),
),
onTap: () => onWaehlen(begriff),
),
],
);
}
}
/// Die Treffer, nach Art getrennt.
class _Ergebnisse extends StatelessWidget {
const _Ergebnisse({required this.treffer, required this.alleSongs});
final SuchTreffer treffer;
/// Grundlage für die Titel eines Künstlers — die Suche liefert nur die
/// Namen, die Zuordnung steckt in der Bibliothek.
final List<Song> alleSongs;
void _oeffne(BuildContext context, String titel, List<Song> songs) {
FocusScope.of(context).unfocus();
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => TitelListenScreen(titel: titel, songs: songs),
));
}
@override
Widget build(BuildContext context) {
final dienst = context.watch<CategoryService>();
// Einmal bündeln statt je Treffer die ganze Bibliothek zu filtern — und
// unter demselben Schlüssel, den die Suche zum Zusammenfassen benutzt.
final nachKuenstler = kuenstlerNachSchluessel(alleSongs);
final nachKategorie =
kategorienNachSchluessel(alleSongs, (s) => dienst.of(s.id));
// Eine Kategorie, an der nur noch gelöschte Titel hängen, steht weiter in
// der Namensliste des Dienstes. Als Treffer wäre sie eine Sackgasse:
// „0 Titel" und ein leerer Bildschirm dahinter.
final kategorien = [
for (final name in treffer.kategorien)
if (nachKategorie.containsKey(suchSchluessel(name))) name,
];
return ListView(
children: [
if (treffer.titel.isNotEmpty) ...[
_AbschnittsKopf(label: 'Titel', anzahl: treffer.titel.length),
for (var i = 0; i < treffer.titel.length && i < _vorschau; i++)
SongZeile(
song: treffer.titel[i],
warteschlange: treffer.titel,
index: i,
),
if (treffer.titel.length > _vorschau)
_MehrZeile(
label: 'Alle ${treffer.titel.length} Titel anzeigen',
onTap: () => _oeffne(context, 'Titel', treffer.titel),
),
],
if (treffer.kuenstler.isNotEmpty) ...[
_AbschnittsKopf(label: 'Künstler', anzahl: treffer.kuenstler.length),
for (final name in treffer.kuenstler.take(_vorschau))
_NamensZeile(
symbol: Icons.person_outline,
name: name,
songs: nachKuenstler[suchSchluessel(name)] ?? const [],
onTap: _oeffne,
),
],
if (kategorien.isNotEmpty) ...[
_AbschnittsKopf(label: 'Kategorien', anzahl: kategorien.length),
for (final name in kategorien.take(_vorschau))
_NamensZeile(
symbol: Icons.label_outline,
name: name,
songs: nachKategorie[suchSchluessel(name)] ?? const [],
onTap: _oeffne,
),
],
if (treffer.playlisten.isNotEmpty) ...[
_AbschnittsKopf(
label: 'Wiedergabelisten', anzahl: treffer.playlisten.length),
for (final liste in treffer.playlisten.take(_vorschau))
ListTile(
leading: const Icon(Icons.queue_music),
title:
Text(liste.name, maxLines: 1, overflow: TextOverflow.ellipsis),
onTap: () {
FocusScope.of(context).unfocus();
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PlaylistDetailScreen(playlist: liste),
));
},
),
],
const SizedBox(height: MeloSpace.md),
],
);
}
}
/// Zeile für einen Künstler oder eine Kategorie: Name und Titelzahl.
class _NamensZeile extends StatelessWidget {
const _NamensZeile({
required this.symbol,
required this.name,
required this.songs,
required this.onTap,
});
final IconData symbol;
final String name;
final List<Song> songs;
final void Function(BuildContext, String, List<Song>) onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(symbol),
title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text('${songs.length} Titel'),
onTap: () => onTap(context, name, songs),
);
}
}
class _AbschnittsKopf extends StatelessWidget {
const _AbschnittsKopf({required this.label, this.anzahl});
final String label;
final int? anzahl;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.md, MeloSpace.md, MeloSpace.xs),
child: Text(
anzahl == null ? label.toUpperCase() : '${label.toUpperCase()} $anzahl',
style: Theme.of(context).textTheme.labelLarge,
),
);
}
}
class _MehrZeile extends StatelessWidget {
const _MehrZeile({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(label, style: const TextStyle(color: MeloTheme.red)),
onTap: onTap,
);
}
}
class _Hinweis extends StatelessWidget {
const _Hinweis(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Center(
child: Text(text, style: const TextStyle(color: MeloTheme.text2)),
);
}
}
+8
View File
@@ -8,6 +8,14 @@ Map<String, List<Song>> groupByArtist(List<Song> songs) {
return _groupBy(songs, (s) => s.artist, unbekannterKuenstler);
}
const ohneAlbum = 'Ohne Album';
/// Gruppiert [songs] nach Album (fehlendes Album → [ohneAlbum]),
/// alphabetisch nach Albumnamen sortiert.
Map<String, List<Song>> groupByAlbum(List<Song> songs) {
return _groupBy(songs, (s) => s.album, ohneAlbum);
}
Map<String, List<Song>> _groupBy(
List<Song> songs,
String? Function(Song) keyOf,
+256 -63
View File
@@ -7,19 +7,144 @@ import '../playlists/create_playlist_dialog.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart';
import '../shared/favorite_button.dart';
import '../shared/lauf_balken.dart';
import 'category_service.dart';
import 'database.dart';
import 'playlist_service.dart';
import 'song_detail_sheet.dart';
import '../shared/theme.dart';
import 'song_media.dart';
/// Häkchen-Kreis anstelle des Covers, solange ausgewählt wird.
class _AuswahlZeichen extends StatelessWidget {
const _AuswahlZeichen({required this.ausgewaehlt});
final bool ausgewaehlt;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 48,
height: 48,
child: Center(
child: Icon(
ausgewaehlt ? Icons.check_circle : Icons.circle_outlined,
color: ausgewaehlt ? MeloTheme.red : MeloTheme.text3,
size: 26,
),
),
);
}
}
/// Höhe einer Songzeile bei normaler Schriftgröße.
///
/// 72 und nicht 64: so hoch ist ein `ListTile` mit Untertitel in Material.
/// Mit 64 schrumpften die Zeilen sichtbar, sobald der Schnellzugriff eine
/// feste Höhe erzwang — gegenüber jeder anderen Liste der App.
const songZeilenHoehe = 72.0;
/// Schriftgröße, an der die Skalierung gemessen wird: die des Titels
/// ([MeloTheme] `bodyLarge`).
const _messSchrift = 15.0;
/// Die Zeilenhöhe bei der eingestellten Schriftgröße.
///
/// Der Schnellzugriff springt über `Index × Höhe` an die richtige Stelle und
/// braucht dafür gleich hohe Zeilen. Eine fest verdrahtete Zahl würde bei
/// großer Systemschrift den Text abschneiden — deshalb mitskaliert.
///
/// Gemessen an einer **echten** Schriftgröße und nicht an 1.0: Android
/// skaliert seit Version 14 nichtlinear, kleine Größen wachsen dort stärker
/// als große. Der Faktor für 1.0 wäre viel zu groß und ließe zwischen den
/// Zeilen Löcher.
double songZeilenHoeheFuer(BuildContext context) =>
songZeilenHoehe *
(MediaQuery.textScalerOf(context).scale(_messSchrift) / _messSchrift);
/// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab.
class SongList extends StatelessWidget {
const SongList(this.songs, {super.key});
const SongList(
this.songs, {
super.key,
this.controller,
this.festeHoehe = false,
this.auswahl,
this.onAuswahlWechsel,
});
final List<Song> songs;
final ScrollController? controller;
/// Alle Zeilen gleich hoch — nötig, wenn ein Schnellzugriff exakt an eine
/// Position springen soll.
final bool festeHoehe;
/// Die gerade ausgewählten Song-IDs. `null` heißt: kein Auswahl-Modus.
/// Alle Listen, die ihn nicht anbieten, lassen beides weg und verhalten
/// sich unverändert.
final Set<String>? auswahl;
final void Function(String songId)? onAuswahlWechsel;
@override
Widget build(BuildContext context) {
final gewaehlt = auswahl;
return ListView.builder(
controller: controller,
itemExtent: festeHoehe ? songZeilenHoeheFuer(context) : null,
itemCount: songs.length,
itemBuilder: (context, i) => SongZeile(
song: songs[i],
warteschlange: songs,
index: i,
auswahlModus: gewaehlt != null && gewaehlt.isNotEmpty,
ausgewaehlt: gewaehlt?.contains(songs[i].id) ?? false,
onAuswahlWechsel: onAuswahlWechsel == null
? null
: () => onAuswahlWechsel!(songs[i].id),
),
);
}
}
/// Eine Zeile der Songliste: Cover, Titel, Untertitel, Herz und Menü.
///
/// Eigenständig, weil die Suche einzelne Zeilen in ihre Abschnitte setzt und
/// eine ganze [SongList] dort eine verschachtelte Scroll-Fläche wäre.
///
/// [warteschlange] ist die Liste, die beim Antippen geladen wird — in der
/// Bibliothek die ganze Liste, in der Suche der Titel-Abschnitt.
class SongZeile extends StatelessWidget {
const SongZeile({
super.key,
required this.song,
required this.warteschlange,
required this.index,
this.auswahlModus = false,
this.ausgewaehlt = false,
this.onAuswahlWechsel,
});
final Song song;
final List<Song> warteschlange;
final int index;
/// Im Auswahl-Modus wählt ein Tipp aus, statt abzuspielen.
final bool auswahlModus;
final bool ausgewaehlt;
/// Fehlt sie, bietet die Zeile keine Auswahl an — langes Drücken tut nichts.
final VoidCallback? onAuswahlWechsel;
/// "Künstler | Kategorie1 · Kategorie2" — wie im UI-Entwurf.
static String _subtitle(Song song, List<String> categories) {
final artist = song.artist ?? 'Unbekannt';
if (categories.isEmpty) return artist;
return '$artist | ${categories.join(' · ')}';
}
/// Öffnet ein Bottom-Sheet mit allen Playlisten, um [song] hinzuzufügen.
Future<void> _showAddToPlaylist(BuildContext context, Song song) async {
Future<void> _showAddToPlaylist(BuildContext context) async {
final db = context.read<MeloDb>();
final service = context.read<PlaylistService>();
await showModalBottomSheet(
@@ -75,15 +200,13 @@ class SongList extends StatelessWidget {
);
}
/// "Künstler | Kategorie1 · Kategorie2" — wie im UI-Entwurf.
static String _subtitle(Song song, List<String> categories) {
final artist = song.artist ?? 'Unbekannt';
if (categories.isEmpty) return artist;
return '$artist | ${categories.join(' · ')}';
}
/// Menü hinter dem Drei-Punkte-Symbol einer Songzeile.
Future<void> _showMenu(BuildContext context, Song song) async {
///
/// Die Offline-Meldungen greifen hier heute nie: diese Liste zeigt nur
/// Titel der Bibliothek, und die liegen als Datei vor. Sie stehen trotzdem
/// da, weil die Alternative — den Rückgabewert wegwerfen und "hinzugefügt"
/// melden — still lügen würde, sobald ein Server-Titel hier ankommt.
Future<void> _showMenu(BuildContext context) async {
final handler = context.read<MeloAudioHandler>();
final categories = context.read<CategoryService>();
final settings = context.read<AppSettings>();
@@ -112,9 +235,10 @@ class SongList extends StatelessWidget {
title: const Text('Als Nächstes spielen'),
onTap: () async {
Navigator.pop(sheetContext);
await handler.playNext(item());
messenger.showSnackBar(const SnackBar(
content: Text('Läuft als Nächstes')));
final ok = await handler.playNext(item());
messenger.showSnackBar(SnackBar(
content: Text(
ok ? 'Läuft als Nächstes' : nichtOfflineHinweis)));
},
),
ListTile(
@@ -124,9 +248,11 @@ class SongList extends StatelessWidget {
'Nur für jetzt — nicht in Favoriten oder Wiedergabelisten'),
onTap: () async {
Navigator.pop(sheetContext);
await handler.addToQueue(item());
messenger.showSnackBar(const SnackBar(
content: Text('Zur Warteschlange hinzugefügt')));
final ok = await handler.addToQueue(item());
messenger.showSnackBar(SnackBar(
content: Text(ok
? 'Zur Warteschlange hinzugefügt'
: nichtOfflineHinweis)));
},
),
ListTile(
@@ -134,7 +260,7 @@ class SongList extends StatelessWidget {
title: const Text('Zu Wiedergabeliste hinzufügen'),
onTap: () {
Navigator.pop(sheetContext);
_showAddToPlaylist(context, song);
_showAddToPlaylist(context);
},
),
],
@@ -145,57 +271,124 @@ class SongList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
// Nullable gelesen: ohne laufenden Player (etwa im Widget-Test) bleibt
// die Zeile darstellbar, nur ohne Lauf-Markierung.
final handler = context.read<MeloAudioHandler?>();
final categories = context.watch<CategoryService>();
final settings = context.watch<AppSettings>();
return ListView.builder(
itemCount: songs.length,
itemBuilder: (context, i) {
final s = songs[i];
final cover = categories.coverFor(s,
groupByCategory: settings.groupCoversByCategory);
return ListTile(
leading: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
final cover = categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory);
return StreamBuilder<MediaItem?>(
stream: handler?.mediaItem,
// Der Strom liefert seinen aktuellen Wert erst im nächsten Frame.
// Ohne diesen Startwert erschiene die laufende Zeile beim Zurückscrollen
// einen Frame lang unmarkiert — Balken und rote Schrift ploppen nach.
initialData: handler?.mediaItem.valueOrNull,
builder: (context, laufSnap) {
final markiert = istAmLaufen(laufSnap.data, song.id);
// Nur die laufende Zeile hört auf den Wiedergabe-Zustand: der meldet
// auch den Pufferstand und feuert mehrmals pro Sekunde. Der
// StreamBuilder bleibt trotzdem immer stehen — verschwände er,
// änderte sich die Form des Baums, und das Herz daneben würde sein
// Abo verlieren und kurz leer aussehen.
return StreamBuilder<PlaybackState>(
stream: markiert ? handler?.playbackState : null,
initialData: handler?.playbackState.valueOrNull,
builder: (context, zustandSnap) => _zeile(
context,
categories: categories,
settings: settings,
cover: cover,
markiert: markiert,
spielt: markiert && (zustandSnap.data?.playing ?? false),
),
title: Text(s.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
_subtitle(s, categories.of(s.id)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FavoriteButton(songId: s.id),
IconButton(
tooltip: 'Mehr',
icon: const Icon(Icons.more_vert),
onPressed: () => _showMenu(context, s),
),
],
),
onTap: () async {
try {
await playSongs(
handler,
songs,
i,
coverOf: (song) => categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory),
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
}
},
);
},
);
}
Widget _zeile(
BuildContext context, {
required CategoryService categories,
required AppSettings settings,
required String? cover,
required bool markiert,
required bool spielt,
}) {
return ListTile(
selected: ausgewaehlt,
selectedTileColor: MeloTheme.surfaceHigh,
// Im Auswahl-Modus tritt das Häkchen an die Stelle des Covers: eine
// zusätzliche Spalte hätte die Zeile schmaler gemacht und den Titel
// beim Umschalten springen lassen.
leading: auswahlModus
? _AuswahlZeichen(ausgewaehlt: ausgewaehlt)
: LaufMarkierung(
markiert: markiert,
spielt: spielt,
radius: 6,
child: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
),
),
title: Text(
song.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: markiert
? const TextStyle(color: MeloTheme.red, fontWeight: FontWeight.w600)
: null,
),
subtitle: Text(
_subtitle(song, categories.of(song.id)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Herz und Menü gelten je einem Titel — im Auswahl-Modus wäre unklar,
// auf welchen sie sich beziehen.
trailing: auswahlModus
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
FavoriteButton(songId: song.id),
IconButton(
tooltip: 'Mehr',
icon: const Icon(Icons.more_vert),
onPressed: () => _showMenu(context),
),
],
),
onLongPress: onAuswahlWechsel,
onTap: () async {
if (auswahlModus) {
onAuswahlWechsel?.call();
return;
}
FocusScope.of(context).unfocus();
final handler = context.read<MeloAudioHandler>();
final messenger = ScaffoldMessenger.of(context);
try {
final uebersprungen = await playSongs(
handler,
warteschlange,
index,
coverOf: (s) => categories.coverFor(s,
groupByCategory: settings.groupCoversByCategory),
);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
);
}
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
},
);
}
}
+36 -2
View File
@@ -1,6 +1,7 @@
import 'package:audio_service/audio_service.dart';
import '../player/audio_handler.dart';
import '../services/navidrome_service.dart';
import 'database.dart';
/// [cover] überschreibt das Coverbild des Songs — damit auf Sperrbildschirm
@@ -19,9 +20,42 @@ MediaItem songToMediaItem(Song s, {String? cover}) {
);
}
/// Meldung für Titel, die der Offline-Modus aus der Warteschlange genommen
/// hat. Der Nutzer soll den Unterschied zwischen "nicht da" und "nicht
/// heruntergeladen" sehen, statt eine stillschweigend kürzere Liste zu hören.
String offlineHinweis(int uebersprungen) => uebersprungen == 1
? 'Offline-Modus: 1 Titel übersprungen — nicht heruntergeladen'
: 'Offline-Modus: $uebersprungen Titel übersprungen — nicht heruntergeladen';
/// Meldung für einen einzelnen Titel, den der Offline-Modus ablehnt.
const nichtOfflineHinweis =
'Offline-Modus: dieser Titel ist nicht heruntergeladen';
/// Ein Titel vom Navidrome-Server als [MediaItem].
///
/// Eine Stelle für beide Aufrufer (Album und Künstler im Server-Browser) —
/// vorher war der Aufbau doppelt von Hand geschrieben, und beide Kopien
/// vergaßen das Coverbild.
MediaItem subsonicToMediaItem(SubsonicSong s, NavidromeService nav) {
final coverId = s.coverId;
return MediaItem(
id: nav.streamUrl(s.id).toString(),
title: s.titel,
artist: s.kuenstler,
album: s.album,
duration: Duration(seconds: s.dauerSekunden),
// Das Cover liegt am Server, nicht als Datei auf dem Gerät.
artUri: coverId == null ? null : nav.coverUrl(coverId),
// Die Server-ID getrennt von der Abspiel-URI mitgeben: nur damit erkennt
// der Player einen Server-Titel (Cache, Scrobble, Bookmark).
extras: {'navidromeId': s.id},
);
}
/// Spielt [songs] ab [startIndex] ab. [coverOf] liefert je Song das
/// anzuzeigende Cover (siehe [songToMediaItem]).
Future<void> playSongs(
/// anzuzeigende Cover (siehe [songToMediaItem]). Gibt zurück, wie viele Titel
/// der Offline-Modus übersprungen hat.
Future<int> playSongs(
MeloAudioHandler handler,
List<Song> songs,
int startIndex, {
+20
View File
@@ -33,6 +33,26 @@ List<Song> sortSongs(List<Song> songs, SortMode mode, {required bool ascending})
return sorted;
}
/// Der Buchstabe, unter dem [titel] im Schnellzugriff steht.
///
/// Benutzt denselben Schlüssel wie die Sortierung — sonst zeigten die
/// Buchstaben der Leiste an Stellen, an denen gar nichts Passendes steht.
String indexBuchstabe(String titel) {
final key = _sortKey(titel);
if (key.isEmpty || !_startsWithLetter(key)) return '#';
return key[0].toUpperCase();
}
/// Erste Position je Buchstabe in einer **nach Namen sortierten** Liste.
/// Die Reihenfolge der Schlüssel entspricht der Reihenfolge in der Liste.
Map<String, int> buchstabenIndex(List<Song> sortiert) {
final index = <String, int>{};
for (var i = 0; i < sortiert.length; i++) {
index.putIfAbsent(indexBuchstabe(sortiert[i].title), () => i);
}
return index;
}
/// AZ zuerst, alles was nicht mit einem Buchstaben beginnt (Ziffern,
/// Sonderzeichen) landet unter "#" am Ende.
int _compareNames(String a, String b) {
+62
View File
@@ -0,0 +1,62 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Wie viele Suchbegriffe gemerkt werden. Mehr passen nicht auf den Schirm,
/// ohne dass die Liste selbst zum Suchproblem wird.
const verlaufMaximum = 8;
/// [verlauf] mit [begriff] vorn: der zuletzt gesuchte Begriff steht oben,
/// jeder Begriff nur einmal, insgesamt höchstens [verlaufMaximum].
///
/// Rein und ohne Speicher — die Reihenfolge-Regeln sind der Teil, der
/// schiefgehen kann.
List<String> verlaufMit(List<String> verlauf, String begriff) {
final neu = begriff.trim();
if (neu.isEmpty) return List<String>.from(verlauf);
final ergebnis = [
neu,
for (final alt in verlauf)
if (alt.toLowerCase() != neu.toLowerCase()) alt,
];
return ergebnis.take(verlaufMaximum).toList();
}
/// [verlauf] ohne [begriff] — zum gezielten Entfernen eines einzelnen
/// Eintrags, ohne den ganzen Verlauf zu leeren.
List<String> verlaufOhne(List<String> verlauf, String begriff) {
return [
for (final alt in verlauf)
if (alt.toLowerCase() != begriff.trim().toLowerCase()) alt,
];
}
/// Merkt sich die letzten Suchbegriffe über einen App-Neustart hinweg.
/// Aufbau wie [SortStore]: statische Methoden auf SharedPreferences.
class SuchVerlauf {
static const _key = 'such_verlauf';
static Future<List<String>> laden() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getStringList(_key) ?? const [];
}
/// Trägt [begriff] ein und gibt den neuen Verlauf zurück.
static Future<List<String>> merke(String begriff) async {
final prefs = await SharedPreferences.getInstance();
final neu = verlaufMit(prefs.getStringList(_key) ?? const [], begriff);
await prefs.setStringList(_key, neu);
return neu;
}
static Future<void> leeren() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key);
}
/// Entfernt [begriff] aus dem Verlauf und gibt den neuen Verlauf zurück.
static Future<List<String>> entferne(String begriff) async {
final prefs = await SharedPreferences.getInstance();
final neu = verlaufOhne(prefs.getStringList(_key) ?? const [], begriff);
await prefs.setStringList(_key, neu);
return neu;
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'database.dart';
/// Ergebnis einer Suche in der Bibliothek, nach Art der Treffer getrennt.
///
/// Bewusst vier Listen statt einer gemischten: der Nutzer sucht entweder ein
/// Lied oder einen Künstler, und eine flache Liste zwingt ihn, das selbst
/// auseinanderzuhalten.
class SuchTreffer {
const SuchTreffer({
this.titel = const [],
this.kuenstler = const [],
this.kategorien = const [],
this.playlisten = const [],
});
final List<Song> titel;
final List<String> kuenstler;
final List<String> kategorien;
final List<Playlist> playlisten;
bool get istLeer =>
titel.isEmpty &&
kuenstler.isEmpty &&
kategorien.isEmpty &&
playlisten.isEmpty;
}
/// Normalform eines Namens für den Abgleich.
///
/// Die Suche fasst „Neoni", „NEONI" und „Neoni " zu einem Treffer zusammen.
/// Wer danach die Titel dieses Namens sucht, muss **dieselbe** Normalform
/// verwenden — sonst zeigt der Treffer „0 Titel" und öffnet eine leere Liste,
/// weil er auf eine Schreibweise zeigt, die so nirgends gespeichert ist.
String suchSchluessel(String name) => name.trim().toLowerCase();
/// Bündelt [songs] unter [suchSchluessel] ihrer Künstler. Nur Titel mit
/// Künstlernamen; verschiedene Schreibweisen fallen zusammen.
Map<String, List<Song>> kuenstlerNachSchluessel(List<Song> songs) {
final gebuendelt = <String, List<Song>>{};
for (final song in songs) {
final name = song.artist?.trim() ?? '';
if (name.isEmpty) continue;
gebuendelt.putIfAbsent(suchSchluessel(name), () => []).add(song);
}
return gebuendelt;
}
/// Bündelt [songs] unter [suchSchluessel] ihrer Kategorien. [kategorienVon]
/// liefert die Kategorien eines Titels (wie `CategoryService.of`).
Map<String, List<Song>> kategorienNachSchluessel(
List<Song> songs,
List<String> Function(Song) kategorienVon,
) {
final gebuendelt = <String, List<Song>>{};
for (final song in songs) {
for (final name in kategorienVon(song)) {
if (name.trim().isEmpty) continue;
gebuendelt.putIfAbsent(suchSchluessel(name), () => []).add(song);
}
}
return gebuendelt;
}
/// Durchsucht die Bibliothek. Rein — ohne Datenbank und ohne Oberfläche,
/// damit die Regeln unten für sich prüfbar sind.
///
/// [kategorien] sind alle vergebenen Kategorienamen. Alben stehen dort mit
/// drin: der Album-Name ist in Melo die erste Kategorie eines Titels (siehe
/// `kategorienAusTags`), ein eigener Alben-Abschnitt wäre also dieselbe Liste
/// zweimal.
SuchTreffer sucheLokal({
required List<Song> songs,
required List<String> kategorien,
required List<Playlist> playlisten,
required String query,
}) {
final gesucht = query.trim().toLowerCase();
if (gesucht.isEmpty) return const SuchTreffer();
return SuchTreffer(
titel: _sortiere(
songs.where((s) => _passtSong(s, gesucht)).toList(),
gesucht,
(s) => s.title,
),
kuenstler: _sortiere(
_eindeutig(songs.map((s) => s.artist))
.where((name) => _enthaelt(name, gesucht))
.toList(),
gesucht,
(name) => name,
),
kategorien: _sortiere(
kategorien.where((name) => _enthaelt(name, gesucht)).toList(),
gesucht,
(name) => name,
),
playlisten: _sortiere(
playlisten.where((p) => _enthaelt(p.name, gesucht)).toList(),
gesucht,
(p) => p.name,
),
);
}
bool _enthaelt(String? wert, String gesucht) =>
wert != null && wert.toLowerCase().contains(gesucht);
/// Ein Titel passt, wenn Titel, Künstler **oder** Album passen — er steht
/// deshalb trotzdem nur einmal in der Trefferliste.
bool _passtSong(Song s, String gesucht) =>
_enthaelt(s.title, gesucht) ||
_enthaelt(s.artist, gesucht) ||
_enthaelt(s.album, gesucht);
/// Nicht-leere Werte, jeder nur einmal, in der Reihenfolge des ersten
/// Auftretens. Groß-/Kleinschreibung entscheidet nicht über Gleichheit.
List<String> _eindeutig(Iterable<String?> werte) {
final gesehen = <String>{};
final ergebnis = <String>[];
for (final wert in werte) {
final name = wert?.trim() ?? '';
if (name.isEmpty) continue;
if (gesehen.add(name.toLowerCase())) ergebnis.add(name);
}
return ergebnis;
}
/// Treffer, die mit der Eingabe **beginnen**, zuerst — danach alphabetisch.
/// Wer „roses" tippt, meint eher „Roses" als „Wild Roses".
List<T> _sortiere<T>(List<T> werte, String gesucht, String Function(T) nameVon) {
int rang(T wert) =>
nameVon(wert).toLowerCase().startsWith(gesucht) ? 0 : 1;
return werte
..sort((a, b) {
final unterschied = rang(a).compareTo(rang(b));
if (unterschied != 0) return unterschied;
return nameVon(a).toLowerCase().compareTo(nameVon(b).toLowerCase());
});
}
+111 -9
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
@@ -5,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'downloads/downloads_screen.dart';
import 'downloads/youtube_search_screen.dart';
import 'library/android_scan.dart';
import 'library/auto_scan.dart';
import 'library/category_service.dart';
@@ -19,11 +21,16 @@ import 'player/audio_handler.dart';
import 'player/mini_player.dart';
import 'services/baka_auth.dart';
import 'services/logger_service.dart';
import 'services/download_service.dart';
import 'services/geteilte_adresse.dart';
import 'services/melo_cloud_service.dart';
import 'services/offline_mode.dart';
import 'services/navidrome_service.dart';
import 'services/sync_service.dart';
import 'services/yt_download_service.dart';
import 'services/yt_search_service.dart';
import 'settings/app_settings.dart';
import 'shared/sync_bericht_dialog.dart';
import 'shared/theme.dart';
late final MeloAudioHandler _handler;
@@ -36,14 +43,15 @@ late final AppSettings _settings;
late final AudioEffects _effects;
late final BakaAuth _bakaAuth;
late final YtDownloadService _ytDownload;
late final YtSearchService _ytSearch;
late final SyncService _sync;
late final DownloadService _downloads;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await logger.init();
_db = MeloDb();
_library = LibraryService(_db);
_playlists = PlaylistService(_db);
_offlineMode = OfflineMode();
await _offlineMode.init();
_categories = CategoryService(_db);
@@ -56,6 +64,12 @@ Future<void> main() async {
androidNotificationChannelName: 'Melo',
androidNotificationOngoing: true,
androidShowNotificationBadge: true,
// Android Auto zeigt die oberste Ebene als Reiter statt als Liste —
// vier Ordner passen dort gut hin.
androidBrowsableRootExtras: {
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 2,
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
},
),
);
_effects = AudioEffects();
@@ -63,11 +77,21 @@ Future<void> main() async {
_bakaAuth = BakaAuth();
await _bakaAuth.laden();
_ytDownload = YtDownloadService(auth: _bakaAuth);
_ytSearch = YtSearchService(auth: _bakaAuth);
_sync = SyncService(
db: _db,
cloud: MeloCloudService(auth: _bakaAuth),
);
await _sync.laden();
// Erst hier: der Sofort-Push braucht den angemeldeten Cloud-Zugang, und
// _bakaAuth entsteht weiter oben.
_playlists = PlaylistService(
_db,
cloud: MeloCloudService(auth: _bakaAuth),
);
final navidrome = NavidromeService();
await navidrome.ladeGespeicherteZugangsdaten();
_downloads = DownloadService(db: _db, navidrome: navidrome);
// Die Audio-Session steht erst beim Abspielen fest und kann wechseln —
// die Klangeffekte müssen dann neu daran gehängt werden.
_handler.androidAudioSessionIdStream.listen((id) {
@@ -76,11 +100,21 @@ Future<void> main() async {
void uebernehmeEinstellungen() {
_handler.autoPlayOnHeadphones = _settings.autoPlayOnHeadphones;
_handler.normalizeVolume = _settings.normalizeVolume;
_handler.cacheGrenzeBytes = _settings.cacheGrenzeBytes;
// Sofort anwenden: wer die Grenze senkt, will nicht warten, bis zufällig
// der nächste Titel gestreamt wird.
unawaited(_handler.raeumeZwischenspeicherAuf());
}
uebernehmeEinstellungen();
_settings.addListener(uebernehmeEinstellungen);
// Der Offline-Schalter liegt in einem eigenen Dienst, wirkt aber genauso:
// ohne diese Weitergabe war er nur gespeichert und die App streamte weiter.
void uebernehmeOfflineModus() => _handler.offlineOnly = _offlineMode.enabled;
uebernehmeOfflineModus();
_offlineMode.addListener(uebernehmeOfflineModus);
runApp(const MeloApp());
}
@@ -101,7 +135,9 @@ class MeloApp extends StatelessWidget {
ChangeNotifierProvider<AudioEffects>.value(value: _effects),
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
ChangeNotifierProvider<YtDownloadService>.value(value: _ytDownload),
ChangeNotifierProvider<YtSearchService>.value(value: _ytSearch),
ChangeNotifierProvider<SyncService>.value(value: _sync),
ChangeNotifierProvider<DownloadService>.value(value: _downloads),
],
child: MaterialApp(
title: 'Melo',
@@ -124,6 +160,9 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
int _index = 0;
late final AutoScan _autoScan;
/// Adresse aus einem „Teilen"-Aufruf, die der Downloader übernehmen soll.
String? _geteilteAdresse;
void _goTo(int index) => setState(() => _index = index);
@override
@@ -139,7 +178,20 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
// Beim Start einmal mit dem Server abgleichen — neue Titel von anderen
// Geräten sind dann sofort da.
context.read<SyncService>().automatisch();
unawaited(_gleicheAbUndZeigeBericht());
unawaited(context.read<LibraryService>().ensureStartupPermissions());
unawaited(_pruefeGeteilteAdresse());
}
/// Sieht nach, ob die App über „Teilen" mit einer Adresse geöffnet wurde,
/// und wechselt dann in den Download-Bereich.
Future<void> _pruefeGeteilteAdresse() async {
final adresse = await const GeteilteAdresse().abholen();
if (adresse == null || !mounted) return;
setState(() {
_geteilteAdresse = adresse;
_index = 2;
});
}
@override
@@ -154,20 +206,55 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
// auf dem Gerät wie am Server.
if (state == AppLifecycleState.resumed) {
if (Platform.isAndroid) _autoScan.pruefe();
context.read<SyncService>().automatisch();
unawaited(_gleicheAbUndZeigeBericht());
// Ein zweites Teilen startet die App nicht neu (singleTop), sondern
// bringt sie nur nach vorn — deshalb auch hier nachsehen.
unawaited(_pruefeGeteilteAdresse());
}
}
/// Gleicht ab und zeigt danach höchstens einmal den „Was ist neu"-Bericht.
Future<void> _gleicheAbUndZeigeBericht() async {
final sync = context.read<SyncService>();
final playlists = context.read<PlaylistService>();
await sync.automatisch();
// Nur auf einem Gerät ohne eigene Playlisten, und nur nach einem
// tatsächlich abgeschlossenen Sync-Lauf: läuft parallel schon ein
// echter Sync (z. B. weil ein Berechtigungsdialog während des
// Erststarts einen App-Resume auslöst und diese Methode ein zweites
// Mal anstößt), kehrt automatisch() oben wegen des _laeuft-Guards
// sofort zurück — dann liefe jede songByCloudId-Suche noch ins Leere,
// legte leere Playlisten an, und der countPlaylists()-Riegel würde
// jeden weiteren Versuch für immer blockieren.
if (!sync.laeuft) {
unawaited(playlists.stelleWiederHer());
}
if (!mounted) return;
final bericht = sync.bericht;
if (bericht == null) return;
// Zuerst quittieren: ein zweites Zurückkehren in die App soll denselben
// Bericht nicht erneut zeigen.
sync.berichtGesehen();
await showDialog<void>(
context: context,
builder: (_) => SyncBerichtDialog(bericht: bericht),
);
}
@override
Widget build(BuildContext context) {
final tabs = <Widget>[
MyMusicScreen(
onSearchTap: () => _goTo(2),
onSearchTap: () => _goTo(1),
onFavoritesTap: () => _goTo(3),
),
const DownloadsScreen(),
const SearchScreen(),
DownloadsScreen(
adresse: _geteilteAdresse,
onAdresseUebernommen: () => setState(() => _geteilteAdresse = null),
),
const FavoritesScreen(),
const YoutubeSearchScreen(),
];
return Scaffold(
body: Column(
@@ -176,18 +263,33 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
const MiniPlayer(),
],
),
bottomNavigationBar: BottomNavigationBar(
// Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
// optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü.
bottomNavigationBar: DecoratedBox(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: MeloTheme.border)),
),
child: BottomNavigationBar(
currentIndex: _index,
onTap: _goTo,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.headphones), label: 'Meine Musik'),
BottomNavigationBarItem(
icon: Icon(Icons.music_note_outlined), label: 'Online'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'),
BottomNavigationBarItem(
icon: Icon(Icons.favorite_border), label: 'Favoriten'),
icon: Icon(Icons.download_outlined),
activeIcon: Icon(Icons.download),
label: 'Download'),
BottomNavigationBarItem(
icon: Icon(Icons.favorite_border),
activeIcon: Icon(Icons.favorite),
label: 'Favoriten'),
BottomNavigationBarItem(
icon: Icon(Icons.smart_display_outlined),
activeIcon: Icon(Icons.smart_display),
label: 'YT-Suche'),
],
),
),
);
}
+344 -27
View File
@@ -4,8 +4,12 @@ import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';
import '../library/database.dart';
import '../library/song_grouping.dart';
import '../library/song_media.dart';
import '../services/cache_manager.dart';
import '../services/download_speicher.dart';
import '../services/navidrome_service.dart';
import 'auto_browse.dart';
import 'headphone_autoplay.dart';
import 'replay_gain.dart';
import 'sleep_timer.dart';
@@ -36,6 +40,43 @@ int playNextIndex({required int? currentIndex, required int queueLength}) {
return (currentIndex + 1).clamp(0, queueLength);
}
/// Entfernt den Titel an [removeIndex] aus [queue].
List<MediaItem> removeFromQueue(List<MediaItem> queue, int removeIndex) {
return List<MediaItem>.from(queue)..removeAt(removeIndex);
}
/// Verschiebt den Titel von [oldIndex] nach [newIndex]. `newIndex` ist bereits
/// um das entfernte Element bereinigt (siehe `ReorderableListView.onReorderItem`)
/// und passt damit zu `AudioPlayer.moveAudioSource`.
List<MediaItem> reorderQueue(List<MediaItem> queue, int oldIndex, int newIndex) {
final result = List<MediaItem>.from(queue);
final moved = result.removeAt(oldIndex);
result.insert(newIndex, moved);
return result;
}
/// Der zuletzt als Wiedergabe gezählte Index, nachdem der Titel an
/// [entfernt] aus der Warteschlange genommen wurde.
///
/// Ohne diese Nachführung zeigt der gemerkte Index auf den Nachbarn: der
/// laufende Titel bekäme beim nächsten `currentIndexStream`-Ereignis eine
/// zweite Wiedergabe gutgeschrieben, obwohl er nie neu gestartet wurde.
int? gezaehlterIndexNachEntfernen(int? gezaehlt, int entfernt) {
if (gezaehlt == null) return null;
if (gezaehlt == entfernt) return null;
return gezaehlt > entfernt ? gezaehlt - 1 : gezaehlt;
}
/// Der zuletzt als Wiedergabe gezählte Index, nachdem ein Titel von [von]
/// nach [nach] verschoben wurde. Siehe [gezaehlterIndexNachEntfernen].
int? gezaehlterIndexNachVerschieben(int? gezaehlt, int von, int nach) {
if (gezaehlt == null) return null;
if (gezaehlt == von) return nach;
if (von < gezaehlt && gezaehlt <= nach) return gezaehlt - 1;
if (nach <= gezaehlt && gezaehlt < von) return gezaehlt + 1;
return gezaehlt;
}
/// Navidrome-Song-ID eines Warteschlangen-Eintrags, oder `null` bei einem
/// Titel vom Gerät.
///
@@ -51,6 +92,15 @@ String? navidromeIdOf(MediaItem item) => item.extras?['navidromeId'] as String?;
/// der nur am Server liegt. Trägt Historie, Wiedergabezähler und Resume.
String? songIdOf(MediaItem item) => item.extras?['songId'] as String?;
/// Ob der Titel mit der Bibliotheks-UUID [songId] gerade in der Wiedergabe
/// steht. [aktuell] ist der laufende Eintrag, oder `null`, wenn nichts läuft.
///
/// Der leere [songId] gilt nie als laufend: Titel, die nur am Server liegen,
/// haben keine UUID, und ohne diese Prüfung markierte ein Server-Titel jede
/// Zeile, der ebenfalls eine fehlt.
bool istAmLaufen(MediaItem? aktuell, String songId) =>
songId.isNotEmpty && aktuell != null && songIdOf(aktuell) == songId;
/// Ob für [item] im Server-Cache nachgesehen wird. Titel vom Gerät liegen
/// bereits als Datei vor — für sie darf der Server nie befragt werden.
bool nutztServerCache(MediaItem item) => navidromeIdOf(item) != null;
@@ -60,6 +110,34 @@ bool nutztServerCache(MediaItem item) => navidromeIdOf(item) != null;
Uri quelleFuer(MediaItem item, {String? cacheTreffer}) =>
cacheTreffer != null ? Uri.file(cacheTreffer) : Uri.parse(item.id);
/// Ob [item] ohne Netz läuft: Titel vom Gerät immer, Server-Titel nur mit
/// vollständigem Cache-Treffer.
bool istOfflineVerfuegbar(MediaItem item, {required bool cacheTreffer}) =>
navidromeIdOf(item) == null || cacheTreffer;
/// Der Startindex, nachdem Titel aus der Warteschlange gefiltert wurden.
/// [behalten] hat einen Eintrag je ursprünglichem Titel.
///
/// Fällt der gewählte Titel selbst weg, rückt der nächste behaltene an seine
/// Stelle — der Nutzer landet dort, wo er getippt hat, und nicht am Anfang.
int startIndexNachFilter(List<bool> behalten, int startIndex) {
var davor = 0;
for (var i = 0; i < startIndex && i < behalten.length; i++) {
if (behalten[i]) davor++;
}
final gesamt = behalten.where((b) => b).length;
if (gesamt == 0) return 0;
return davor.clamp(0, gesamt - 1);
}
/// Im Offline-Modus ist keiner der gewählten Titel ohne Netz abspielbar.
class NichtsOfflineVerfuegbar implements Exception {
const NichtsOfflineVerfuegbar();
@override
String toString() => 'Im Offline-Modus ist keiner dieser Titel verfügbar';
}
/// Kern der Wiedergabe: kapselt just_audio hinter audio_service,
/// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren.
class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
@@ -68,7 +146,18 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
late final SleepTimer sleepTimer;
late final Timer _positionRecordTimer;
late final CacheManager _cache;
final DownloadSpeicher _downloads = DownloadSpeicher();
/// Cache-Dateien, die gerade als Quelle in der Warteschlange stehen. Sie
/// dürfen nicht verdrängt werden — die Pfade sind im Player schon fest
/// eingetragen, eine gelöschte Datei ließe ihn mitten im Album abbrechen.
final Set<String> _benutzteCacheDateien = {};
final NavidromeService _nav = NavidromeService();
/// Die Server-Verbindung des Players — hier bereits mit geladenen
/// Zugangsdaten. Die Oberfläche nutzt sie mit, statt eine zweite
/// Verbindung aufzubauen, die davon nichts wüsste.
NavidromeService get navidrome => _nav;
int? _lastCountedIndex;
/// Wiedergabe automatisch starten, sobald Kopfhörer verbunden werden.
@@ -79,6 +168,20 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
/// Wird von den App-Einstellungen gesetzt.
bool normalizeVolume = true;
/// Nur abspielen, was ohne Netz da ist. Wird vom Offline-Schalter in den
/// Einstellungen gesetzt; siehe [loadPlaylist].
bool offlineOnly = false;
/// Obergrenze des Zwischenspeichers in Bytes. Wird von den Einstellungen
/// gesetzt; bewusst heruntergeladene Titel liegen woanders und zählen nicht
/// mit (siehe [DownloadSpeicher]).
///
/// **0 heißt „nichts behalten"**, nicht „unbegrenzt" — die Einstellung
/// bietet genau diese Wahl an. Der Startwert entspricht der Voreinstellung
/// der App, damit ein noch nicht übernommener Wert nie versehentlich den
/// ganzen Zwischenspeicher leert.
int cacheGrenzeBytes = 2048 * 1024 * 1024;
MeloAudioHandler({required this.db}) {
_cache = CacheManager();
_cache.init();
@@ -112,6 +215,12 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
if (index != null && index < q.length) {
mediaItem.add(q[index].copyWith(duration: _player.duration));
_applyGain(q[index]);
// Jetzt läuft er wirklich — erst das macht ihn im Sinne der
// Verdrängung zu einem „zuletzt gehörten" Titel.
final navId = navidromeIdOf(q[index]);
if (navId != null) {
unawaited(_cache.beruehre(NavidromeService.cacheSchluessel(navId)));
}
if (shouldCountPlay(index, _lastCountedIndex)) {
_lastCountedIndex = index;
final songId = songIdOf(q[index]);
@@ -159,51 +268,103 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
Stream<int?> get androidAudioSessionIdStream =>
_player.androidAudioSessionIdStream;
/// Ersetzt die Warteschlange und startet ab [startIndex].
/// Ersetzt die Warteschlange und startet ab [startIndex]. Gibt zurück, wie
/// viele Titel der Offline-Modus übersprungen hat (sonst 0).
///
/// Baut die Quellen ohne einen einzigen Netzzugriff: [MediaItem.id] ist
/// bereits abspielbar. Früher lud diese Schleife die *ganze* Warteschlange
/// vorab vom Server herunter — bei einer Bibliothek mit hunderten Titeln
/// begann die Wiedergabe deshalb nie, und ein zweiter Tipp brach den
/// laufenden Ladevorgang ab ("Loading interrupted").
Future<void> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
///
/// Ist [offlineOnly] gesetzt, fallen Server-Titel ohne Cache-Treffer heraus,
/// statt still gestreamt zu werden. Wirft [NichtsOfflineVerfuegbar], wenn
/// dadurch nichts übrig bleibt.
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
// Ohne Titel gibt es nichts zu tun. Ohne diese Zeile sähe der Filter
// unten "alle übersprungen" und meldete fälschlich den Offline-Modus.
if (items.isEmpty) return 0;
// Die alte Warteschlange ist gleich weg; ihre Dateien dürfen wieder
// verdrängt werden.
_benutzteCacheDateien.clear();
final quellen = [for (final item in items) await _quelle(item)];
final behalten = [for (final quelle in quellen) quelle != null];
final uebersprungen = behalten.where((b) => !b).length;
if (uebersprungen == items.length) throw const NichtsOfflineVerfuegbar();
final gefiltert = <MediaItem>[];
final sources = <AudioSource>[];
for (var i = 0; i < items.length; i++) {
final quelle = quellen[i];
if (quelle == null) continue;
gefiltert.add(items[i]);
sources.add(quelle);
}
final start = startIndexNachFilter(behalten, startIndex);
queue.add(gefiltert);
// Neue Warteschlange: der erste Titel soll wieder zählen, auch wenn er
// denselben Index wie der zuletzt gezählte hat.
_lastCountedIndex = null;
final sources = <AudioSource>[];
for (final item in items) {
// Ein vollständig zwischengespeicherter Server-Titel läuft offline.
final navId = navidromeIdOf(item);
final treffer = navId == null
? null
: await _cache.getCachedFile(NavidromeService.cacheSchluessel(navId));
sources.add(AudioSource.uri(
quelleFuer(item, cacheTreffer: treffer?.path),
tag: item,
));
}
await _player.setAudioSources(sources, initialIndex: startIndex);
await _player.setAudioSources(sources, initialIndex: start);
// Bei bekannter letzter Position an dieser Stelle fortsetzen,
// statt immer von vorne zu beginnen.
final item = items[startIndex];
final item = gefiltert[start];
final resumeMs = await _fortsetzPosition(item);
if (resumeMs != null) {
await _player.seek(Duration(milliseconds: resumeMs), index: startIndex);
await _player.seek(Duration(milliseconds: resumeMs), index: start);
}
await play();
// Server-Titel im Hintergrund ablegen — das darf den Start nie aufhalten.
final navId = navidromeIdOf(item);
if (navId != null) {
unawaited(_nav.ladeInCache(navId, Uri.parse(item.id), _cache));
// Wer den Titel bewusst heruntergeladen hat, braucht keine zweite Kopie
// im Zwischenspeicher — das wäre doppelter Platz und doppeltes Datenvolumen.
if (navId != null && await _downloads.treffer(navId) == null) {
unawaited(_nav.ladeInCache(navId, Uri.parse(item.id), _cache).then((_) {
// Direkt nach dem Ablegen aufräumen — sonst wächst der
// Zwischenspeicher weiter unbegrenzt.
raeumeZwischenspeicherAuf();
}));
}
return uebersprungen;
}
/// Baut die Abspiel-Quelle für [item] und sieht dabei einmal im Cache nach:
/// ein vollständig zwischengespeicherter Server-Titel läuft ohne Netz.
///
/// Gibt `null` zurück, wenn [offlineOnly] gesetzt ist und der Titel das Netz
/// bräuchte. Eine Stelle für alle drei Wege in die Warteschlange — sonst
/// gälte der Offline-Modus beim Starten, aber nicht beim Anhängen.
Future<AudioSource?> _quelle(MediaItem item) async {
final navId = navidromeIdOf(item);
// Erst die bewussten Downloads, dann der Zwischenspeicher: was der Nutzer
// mitgenommen hat, soll nie am Netz hängen — auch nicht, wenn zufällig
// eine Kopie im Cache liegt, die morgen verdrängt sein kann.
final treffer = navId == null
? null
: await _downloads.treffer(navId) ??
await _cache.getCachedFile(NavidromeService.cacheSchluessel(navId));
if (offlineOnly &&
!istOfflineVerfuegbar(item, cacheTreffer: treffer != null)) {
return null;
}
if (treffer != null) _benutzteCacheDateien.add(treffer.path);
return AudioSource.uri(
quelleFuer(item, cacheTreffer: treffer?.path),
tag: item,
);
}
/// Hält den Zwischenspeicher unter der eingestellten Grenze, ohne dabei
/// Dateien der laufenden Warteschlange anzufassen.
Future<void> raeumeZwischenspeicherAuf() =>
_cache.begrenzeAuf(cacheGrenzeBytes, geschuetzt: _benutzteCacheDateien);
/// Position, an der [item] fortgesetzt wird, oder `null` für den Anfang.
/// Der Server zählt nur bei Server-Titeln — für Titel vom Gerät gilt
/// allein die lokale Historie.
@@ -228,33 +389,189 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
/// Hängt [item] hinten an die Warteschlange an, ohne die laufende
/// Wiedergabe zu unterbrechen. Favoriten und Wiedergabelisten bleiben
/// unberührt — die Warteschlange ist nur für diese Sitzung.
Future<void> addToQueue(MediaItem item) async {
await _player.addAudioSource(AudioSource.uri(Uri.parse(item.id), tag: item));
///
/// `false`, wenn der Offline-Modus den Titel ausschließt.
Future<bool> addToQueue(MediaItem item) async {
final quelle = await _quelle(item);
if (quelle == null) return false;
await _player.addAudioSource(quelle);
queue.add([...queue.value, item]);
return true;
}
/// Spielt [item] als Nächstes, direkt nach dem laufenden Titel.
Future<void> playNext(MediaItem item) async {
/// `false`, wenn der Offline-Modus den Titel ausschließt.
///
/// Eingefügt wird immer *hinter* dem laufenden Titel — der zuletzt gezählte
/// Index verschiebt sich dadurch nie.
Future<bool> playNext(MediaItem item) async {
final quelle = await _quelle(item);
if (quelle == null) return false;
final index = playNextIndex(
currentIndex: _player.currentIndex,
queueLength: queue.value.length,
);
await _player.insertAudioSource(
index, AudioSource.uri(Uri.parse(item.id), tag: item));
await _player.insertAudioSource(index, quelle);
final updated = [...queue.value]..insert(index, item);
queue.add(updated);
return true;
}
/// Nimmt den Titel an [index] aus der Warteschlange, ohne die Wiedergabe
/// zu unterbrechen.
///
/// Früher baute diese Aktion die ganze Warteschlange mit [loadPlaylist] neu
/// auf: die Wiedergabe setzte hörbar aus, sprang an den Anfang des Titels
/// zurück und wurde erneut gezählt. `just_audio` kann einzelne Quellen
/// entfernen — der laufende Titel merkt davon nichts.
/// Warteschlange und Zählerstand werden **vor** dem Player gesetzt.
///
/// `just_audio` sendet beim Umbauen seiner Quellenliste noch während des
/// `await` neue Index-Ereignisse. Käme unsere Buchführung erst danach, sähe
/// der Zuhörer auf `currentIndexStream` (siehe Konstruktor) die *alte*
/// Warteschlange und meldete den falschen Titel — samt Wiedergabe-Zählung
/// für ein Lied, das gar nicht läuft. [loadPlaylist] macht es genauso.
@override
Future<void> removeQueueItemAt(int index) async {
final aktuell = queue.value;
if (index < 0 || index >= aktuell.length) return;
final rest = removeFromQueue(aktuell, index);
queue.add(rest);
_lastCountedIndex = gezaehlterIndexNachEntfernen(_lastCountedIndex, index);
await _player.removeAudioSourceAt(index);
// War es der letzte Titel, bleibt sonst eine Benachrichtigung ohne
// Wiedergabe stehen — mitsamt Knöpfen, die ins Leere greifen.
if (rest.isEmpty) await stop();
}
/// Verschiebt den Titel von [oldIndex] nach [newIndex], ohne die Wiedergabe
/// zu unterbrechen. Siehe [removeQueueItemAt] — auch hier steht die
/// Buchführung vor dem Player.
Future<void> moveQueueItem(int oldIndex, int newIndex) async {
final aktuell = queue.value;
if (oldIndex < 0 || oldIndex >= aktuell.length) return;
if (newIndex < 0 || newIndex >= aktuell.length) return;
if (oldIndex == newIndex) return;
queue.add(reorderQueue(aktuell, oldIndex, newIndex));
_lastCountedIndex =
gezaehlterIndexNachVerschieben(_lastCountedIndex, oldIndex, newIndex);
await _player.moveAudioSource(oldIndex, newIndex);
}
// === Blättern im Auto und beim Sprachassistenten ===
//
// Der MediaBrowserService war im Manifest zwar angemeldet, lieferte aber
// nie Inhalte: ohne diese Methoden blieb die Bibliothek dort leer.
@override
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]) async {
if (parentMediaId == AudioService.browsableRootId) {
return [for (final art in wurzelArten) _ordner(art)];
}
final knoten = knotenAus(parentMediaId);
if (knoten == null) return const [];
switch (knoten.art) {
case AutoArt.favoriten:
return _alsMedien(await db.watchFavorites().first);
case AutoArt.zuletzt:
return _alsMedien(await db.watchRecent(limit: 100).first);
case AutoArt.kategorien:
final namen = await db.allCategoryNames();
return [
for (final name in namen) _ordner(AutoArt.kategorie, name),
];
case AutoArt.kuenstlerListe:
final songs = await db.watchSongs().first;
return [
for (final name in groupByArtist(songs).keys)
_ordner(AutoArt.kuenstler, name),
];
case AutoArt.kategorie:
return _alsMedien(await _titelDerKategorie(knoten.name));
case AutoArt.kuenstler:
final songs = await db.watchSongs().first;
return _alsMedien(groupByArtist(songs)[knoten.name] ?? const []);
case AutoArt.titel:
return const [];
}
}
@override
Future<MediaItem?> getMediaItem(String mediaId) async {
final knoten = knotenAus(mediaId);
if (knoten?.art != AutoArt.titel || knoten?.name == null) return null;
final song = await db.songById(knoten!.name!);
return song == null ? null : songToMediaItem(song);
}
/// Wird aufgerufen, wenn im Auto ein Titel angetippt wird. Es läuft nicht
/// nur dieser eine, sondern der Ordner ab dieser Stelle — sonst hörte die
/// Musik nach einem Lied auf.
@override
Future<void> playFromMediaId(String mediaId,
[Map<String, dynamic>? extras]) async {
final knoten = knotenAus(mediaId);
if (knoten == null) return;
if (knoten.art == AutoArt.titel && knoten.name != null) {
final ordner = extras?['ordner'] as String?;
final geschwister =
ordner == null ? const <MediaItem>[] : await getChildren(ordner);
final index =
geschwister.indexWhere((m) => songIdOf(m) == knoten.name);
if (index != -1) {
await loadPlaylist(geschwister, startIndex: index);
return;
}
final song = await db.songById(knoten.name!);
if (song != null) await loadPlaylist([songToMediaItem(song)]);
return;
}
// Ein Ordner: alles darin von vorn.
final inhalt = await getChildren(mediaId);
final titel = [for (final m in inhalt) if (songIdOf(m) != null) m];
if (titel.isNotEmpty) await loadPlaylist(titel);
}
Future<List<Song>> _titelDerKategorie(String? name) async {
if (name == null) return const [];
final songs = await db.watchSongs().first;
final zuordnung = await db.watchCategoriesBySong().first;
return [
for (final song in songs)
if (zuordnung[song.id]?.contains(name) ?? false) song,
];
}
MediaItem _ordner(AutoArt art, [String? name]) => MediaItem(
id: knotenId(art, name),
title: name ?? art.anzeige,
playable: false,
);
List<MediaItem> _alsMedien(List<Song> songs) => [
for (final song in songs)
songToMediaItem(song).copyWith(id: knotenId(AutoArt.titel, song.id)),
];
@override
Future<void> play() => _player.play();
@override
Future<void> pause() => _player.pause();
/// Der Positions-Timer wird hier bewusst **nicht** abgebrochen.
///
/// Er ist ein `Timer.periodic` und entsteht nur einmal im Konstruktor —
/// einmal abgebrochen, käme er für den Rest der App-Sitzung nicht zurück.
/// [stop] löst aber nicht nur der Nutzer aus: das System ruft es beim
/// Wegwischen der Benachrichtigung auf. Danach merkte sich die App keine
/// Hörposition mehr und scrobbelte nicht mehr — bis zum Neustart und ohne
/// jeden Hinweis. Er kostet nichts: seine erste Zeile steigt aus, solange
/// nichts läuft. Abgebrochen wird er allein in [dispose].
@override
Future<void> stop() async {
sleepTimer.cancel();
_positionRecordTimer.cancel();
await _player.stop();
await super.stop();
}
+59
View File
@@ -0,0 +1,59 @@
/// Was für ein Knoten im Browse-Baum von Android Auto und dem
/// Sprachassistenten gemeint ist.
enum AutoArt {
favoriten('Favoriten'),
zuletzt('Zuletzt hinzugefügt'),
kategorien('Kategorien'),
kuenstlerListe('Künstler'),
kategorie(''),
kuenstler(''),
titel('');
const AutoArt(this.anzeige);
/// Beschriftung eines Ordners der obersten Ebene.
final String anzeige;
}
/// Die Ordner, die im Auto auf der obersten Ebene stehen — dieselben Wege,
/// die auch die App anbietet.
const wurzelArten = [
AutoArt.favoriten,
AutoArt.zuletzt,
AutoArt.kategorien,
AutoArt.kuenstlerListe,
];
/// Ein aufgelöster Knoten: seine Art und, wo nötig, sein Name.
class AutoKnoten {
const AutoKnoten(this.art, this.name);
final AutoArt art;
/// Kategorie- oder Künstlername, bei einem Titel dessen UUID.
final String? name;
}
const _praefix = 'melo:';
/// Adresse eines Knotens.
///
/// Der Name steht **hinter** dem ersten Doppelpunkt nach der Art und wird
/// nicht weiter zerlegt: „Intro: Der Anfang" ist ein normaler Albumtitel,
/// und Schrägstriche kommen in Künstlernamen wie „AC/DC" vor. Würde hier
/// gesplittet, verlöre man beides.
String knotenId(AutoArt art, [String? name]) =>
name == null ? '$_praefix${art.name}' : '$_praefix${art.name}:$name';
/// Löst eine Adresse wieder auf, oder `null`, wenn sie nicht von uns stammt.
AutoKnoten? knotenAus(String mediaId) {
if (!mediaId.startsWith(_praefix)) return null;
final rest = mediaId.substring(_praefix.length);
final trenner = rest.indexOf(':');
final artName = trenner == -1 ? rest : rest.substring(0, trenner);
final name = trenner == -1 ? null : rest.substring(trenner + 1);
for (final art in AutoArt.values) {
if (art.name == artName) return AutoKnoten(art, name);
}
return null;
}
+146
View File
@@ -0,0 +1,146 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'dart:io';
import '../services/cover_cache.dart';
import '../shared/theme.dart';
/// Wie viele der acht Bits je Farbkanal beim Einsortieren wegfallen —
/// es bleiben also 5, das sind 32 Stufen je Kanal.
///
/// Ohne dieses Zusammenfassen hätte fast jeder Bildpunkt seine eigene Farbe
/// und „die häufigste" wäre bedeutungslos.
const _verworfeneBits = 3;
/// Bildpunkte unter dieser Deckkraft zählen nicht mit.
const _mindestDeckkraft = 128;
/// Die prägende Farbe eines Coverbilds, aus rohen RGBA-Daten.
///
/// Nicht einfach die häufigste: Cover sind oft überwiegend dunkel oder grau,
/// und dann sähe der Player für jeden Titel gleich aus. Häufigkeit **und**
/// Farbigkeit entscheiden zusammen.
///
/// Rein und ohne Bild-Dekodierung — der Teil, der schiefgehen kann, lässt
/// sich so für sich prüfen. Fällt auf [MeloTheme.red] zurück, wenn nichts
/// Farbiges übrig bleibt (Schwarz-Weiß-Cover, leere Daten).
Color dominanteFarbe(Uint8List rgba) {
final anzahl = <int, int>{};
// Vier Bytes je Bildpunkt; abgeschnittene Daten am Ende werden übergangen.
for (var i = 0; i + 3 < rgba.length; i += 4) {
if (rgba[i + 3] < _mindestDeckkraft) continue;
final topf = (rgba[i] >> _verworfeneBits) << 16 |
(rgba[i + 1] >> _verworfeneBits) << 8 |
(rgba[i + 2] >> _verworfeneBits);
anzahl[topf] = (anzahl[topf] ?? 0) + 1;
}
if (anzahl.isEmpty) return MeloTheme.red;
Color? beste;
var bestesGewicht = 0.0;
anzahl.forEach((topf, haeufigkeit) {
final farbe = _ausTopf(topf);
final hsv = HSVColor.fromColor(farbe);
// Fast-Schwarz und Fast-Weiß tragen keine Aussage über den Charakter
// eines Covers und fallen ganz heraus.
if (hsv.value < 0.15 || (hsv.saturation < 0.15 && hsv.value > 0.9)) return;
// Farbigkeit zählt doppelt: ein kleiner kräftiger Fleck sagt mehr über
// ein Cover aus als eine große graue Fläche.
final gewicht = haeufigkeit * (0.2 + hsv.saturation * 2);
if (gewicht > bestesGewicht) {
bestesGewicht = gewicht;
beste = farbe;
}
});
return beste ?? MeloTheme.red;
}
Color _ausTopf(int topf) {
// Die Mitte des Topfes statt seiner unteren Ecke — sonst wirkt jede Farbe
// etwas zu dunkel.
const mitte = 1 << (_verworfeneBits - 1);
return Color.fromARGB(
255,
(((topf >> 16) & 0x1F) << _verworfeneBits) + mitte,
(((topf >> 8) & 0x1F) << _verworfeneBits) + mitte,
((topf & 0x1F) << _verworfeneBits) + mitte,
);
}
/// [farbe] so weit abgedunkelt, dass weiße Schrift darauf lesbar bleibt.
///
/// Ein knalliges Gelb als Hintergrund würde die Titelzeile verschlucken; die
/// Markenfarben der App sind darauf ausgelegt, auf Dunklem zu stehen.
Color lesbarAuf(Color farbe) {
var hsl = HSLColor.fromColor(farbe);
// Sättigung leicht zurücknehmen: ein voll gesättigter Hintergrund flimmert
// hinter Text.
hsl = hsl.withSaturation((hsl.saturation * 0.8).clamp(0.0, 1.0));
var ergebnis = hsl.toColor();
var versuche = 0;
while (ergebnis.computeLuminance() > 0.12 && versuche < 12) {
hsl = hsl.withLightness((hsl.lightness * 0.85).clamp(0.0, 1.0));
ergebnis = hsl.toColor();
versuche++;
}
return ergebnis;
}
/// Holt die prägende Farbe eines Coverbilds.
///
/// Das Bild wird klein dekodiert (32 × 32): für die Farbwahl reicht das
/// vollkommen, und ein volles Albumcover in Originalgröße zu dekodieren
/// würde bei jedem Titelwechsel spürbar bremsen.
Future<Color> farbeAusBild(ImageProvider bild) async {
try {
final daten = await _rohDaten(bild);
return daten == null ? MeloTheme.red : dominanteFarbe(daten);
} catch (e) {
debugPrint('Cover-Farbe nicht ermittelbar: $e');
return MeloTheme.red;
}
}
Future<Uint8List?> _rohDaten(ImageProvider bild) async {
final vervollstaendigt = Completer<ui.Image>();
final strom = bild.resolve(const ImageConfiguration(
size: Size(32, 32),
));
late final ImageStreamListener zuhoerer;
zuhoerer = ImageStreamListener(
(info, _) {
if (!vervollstaendigt.isCompleted) vervollstaendigt.complete(info.image);
strom.removeListener(zuhoerer);
},
onError: (fehler, spur) {
if (!vervollstaendigt.isCompleted) vervollstaendigt.completeError(fehler);
strom.removeListener(zuhoerer);
},
);
strom.addListener(zuhoerer);
final bilddaten = await vervollstaendigt.future;
final bytes =
await bilddaten.toByteData(format: ui.ImageByteFormat.rawRgba);
return bytes?.buffer.asUint8List();
}
/// Die Bildquelle hinter einer Cover-Adresse.
///
/// Server-Cover kommen über den Ordner (siehe [CoverCache]) und nicht direkt
/// aus dem Netz: sonst würde die Farbwahl bei jedem Titelwechsel ein zweites
/// Mal dasselbe Bild laden.
Future<ImageProvider?> coverBildQuelle(Uri adresse) async {
if (adresse.scheme == 'file') {
final datei = File(adresse.toFilePath());
return await datei.exists() ? FileImage(datei) : null;
}
if (adresse.scheme == 'http' || adresse.scheme == 'https') {
final datei = await CoverCache.hole(adresse);
return datei == null ? null : FileImage(datei);
}
return null;
}
+63
View File
@@ -0,0 +1,63 @@
/// Eine Zeile eines mitlaufenden Songtexts.
class LyricZeile {
const LyricZeile({required this.zeit, required this.text});
final Duration zeit;
final String text;
}
/// Zeitmarke am Zeilenanfang: `[mm:ss]`, `[mm:ss.x]`, `[mm:ss.xx]` oder
/// `[mm:ss.xxx]`. Mehrere Marken vor derselben Zeile sind erlaubt und im
/// Refrain üblich.
final _marke = RegExp(r'\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]');
/// Ob [text] ein LRC-Text ist — also überhaupt Zeitmarken trägt.
///
/// Der Songtext kann aus dem Tag der Datei oder vom Server kommen, und beides
/// liefert mal die eine, mal die andere Form. Statt zu raten, wird geschaut.
bool istSynchron(String text) => _marke.hasMatch(text);
/// Zerlegt einen LRC-Text in Zeilen mit Zeitmarke, nach Zeit sortiert.
///
/// Gibt eine leere Liste zurück, wenn keine Marken vorkommen — dann ist es
/// eine gewöhnliche Textwand und wird auch so angezeigt.
List<LyricZeile> parseLrc(String text) {
final zeilen = <LyricZeile>[];
for (final roh in text.split('\n')) {
final marken = _marke.allMatches(roh).toList();
if (marken.isEmpty) continue;
// Alles hinter der letzten Marke ist der Text. Kopfzeilen wie `[ar:Neoni]`
// passen nicht auf das Muster und fallen schon oben heraus.
final inhalt = roh.substring(marken.last.end).trim();
for (final marke in marken) {
zeilen.add(LyricZeile(zeit: _zeitAus(marke), text: inhalt));
}
}
zeilen.sort((a, b) => a.zeit.compareTo(b.zeit));
return zeilen;
}
Duration _zeitAus(RegExpMatch marke) {
final minuten = int.parse(marke.group(1)!);
final sekunden = int.parse(marke.group(2)!);
final bruch = marke.group(3);
// `.5` sind fünf Zehntel, `.05` fünf Hundertstel, `.050` fünfzig
// Tausendstel — die Stellenzahl entscheidet, nicht der Wert.
final millis = bruch == null
? 0
: (int.parse(bruch) * (bruch.length == 1 ? 100 : bruch.length == 2 ? 10 : 1));
return Duration(minutes: minuten, seconds: sekunden, milliseconds: millis);
}
/// Index der Zeile, die bei [position] gilt — die letzte, deren Marke bereits
/// erreicht ist. Vor der ersten Marke gilt die erste Zeile, damit nie eine
/// leere Anzeige entsteht.
int? aktiveZeile(List<LyricZeile> zeilen, Duration position) {
if (zeilen.isEmpty) return null;
var index = 0;
for (var i = 0; i < zeilen.length; i++) {
if (zeilen[i].zeit > position) break;
index = i;
}
return index;
}
+109 -37
View File
@@ -6,6 +6,7 @@ import '../shared/cover.dart';
import '../shared/theme.dart';
import 'audio_handler.dart';
import 'now_playing_screen.dart';
import 'wischen.dart';
/// Kompakte Wiedergabe-Leiste über der Bottom-Nav.
/// Blendet sich aus, wenn nichts läuft.
@@ -25,7 +26,7 @@ class MiniPlayer extends StatelessWidget {
// Ein- und Ausblenden statt Aufpoppen: die Leiste kommt von unten
// dazu, wenn etwas startet.
return AnimatedSwitcher(
duration: MeloMotion.normal,
duration: MeloMotion.ruhig(context, MeloMotion.normal),
switchInCurve: MeloMotion.curve,
transitionBuilder: (kind, animation) => SizeTransition(
sizeFactor: animation,
@@ -41,15 +42,40 @@ class MiniPlayer extends StatelessWidget {
}
}
class _Leiste extends StatelessWidget {
class _Leiste extends StatefulWidget {
const _Leiste({required this.handler, required this.item});
final MeloAudioHandler handler;
final MediaItem item;
@override
State<_Leiste> createState() => _LeisteState();
}
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;
void _oeffne(BuildContext context) => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
);
void _zurueckschnappen() {
setState(() {
_ziehtGerade = false;
_versatz = Offset.zero;
});
}
@override
Widget build(BuildContext context) {
final texte = Theme.of(context).textTheme;
final handler = widget.handler;
final item = widget.item;
return DecoratedBox(
decoration: const BoxDecoration(
color: MeloTheme.surface,
@@ -61,42 +87,88 @@ class _Leiste extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const NowPlayingScreen(),
)),
child: SizedBox(
height: MiniPlayer.hoehe,
child: Row(
children: [
const SizedBox(width: MeloSpace.md),
CoverImage(artUri: item.artUri, size: 52),
const SizedBox(width: MeloSpace.md),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: texte.bodyLarge
?.copyWith(fontWeight: FontWeight.w600),
),
Text(
item.artist ?? 'Unbekannt',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: texte.bodySmall,
),
],
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);
},
onHorizontalDragUpdate: (details) => setState(() {
_ziehtGerade = true;
_versatz = Offset(_versatz.dx + details.delta.dx, _versatz.dy);
}),
onHorizontalDragEnd: (details) {
final wechsel = titelWechselMitStrecke(
_versatz.dx, details.velocity.pixelsPerSecond.dx);
_zurueckschnappen();
switch (wechsel) {
case Wechsel.naechster:
handler.skipToNext();
case Wechsel.vorheriger:
handler.skipToPrevious();
case null:
break;
}
},
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.zero
: MeloMotion.ruhig(context, MeloMotion.normal),
curve: MeloMotion.curve,
transform:
Matrix4.translationValues(_versatz.dx, _versatz.dy, 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),
),
),
const SizedBox(width: MeloSpace.sm),
_Bedienung(handler: handler),
const SizedBox(width: MeloSpace.sm),
],
const SizedBox(width: MeloSpace.md),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: texte.bodyLarge
?.copyWith(fontWeight: FontWeight.w600),
),
Text(
item.artist ?? 'Unbekannt',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: texte.bodySmall,
),
],
),
),
const SizedBox(width: MeloSpace.sm),
_Bedienung(handler: handler),
const SizedBox(width: MeloSpace.sm),
],
),
),
),
),
+369 -61
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -6,15 +8,20 @@ import '../library/database.dart';
import '../services/navidrome_service.dart';
import '../shared/cover.dart';
import '../shared/favorite_button.dart';
import '../shared/server_favorite_button.dart';
import '../shared/theme.dart';
import 'audio_handler.dart';
import 'cover_farbe.dart';
import 'lrc.dart';
import 'queue_screen.dart';
import 'wischen.dart';
/// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls.
class NowPlayingScreen extends StatelessWidget {
const NowPlayingScreen({super.key});
void _showLyrics(BuildContext context, String songId) {
if (songId.isEmpty) return;
showModalBottomSheet(
context: context,
builder: (ctx) => _LyricsSheet(songId: songId),
@@ -24,7 +31,12 @@ class NowPlayingScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
return Scaffold(
return _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: [
@@ -33,16 +45,26 @@ class NowPlayingScreen extends StatelessWidget {
builder: (context, snapshot) {
final item = snapshot.data;
if (item == null) return const SizedBox.shrink();
// Bei Server-Titeln die Navidrome-ID, nicht die Abspiel-Adresse:
// mit der fragte die App den Server nach einem Titel namens
// "https://…" und bekam nie einen Text.
final songId = songIdOf(item) ?? navidromeIdOf(item) ?? '';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Songtext',
tooltip: songId.isEmpty
? 'Kein Songtext für diesen Titel verfügbar'
: 'Songtext',
icon: const Icon(Icons.lyrics),
onPressed: () => _showLyrics(
context, item.extras?['songId'] as String? ?? item.id),
onPressed: songId.isEmpty
? null
: () => _showLyrics(context, songId),
),
FavoriteButton(songId: item.extras?['songId'] as String? ?? ''),
// Ein Titel der Bibliothek wird lokal favorisiert, einer
// vom Server am Server — beide bekommen dasselbe Herz,
// statt dass eines davon ausgegraut bleibt.
_Herz(item: item),
],
);
},
@@ -58,7 +80,16 @@ class NowPlayingScreen extends StatelessWidget {
),
],
),
body: SafeArea(
body: GestureDetector(
// Wie bei Apple Music/Spotify: den Vollbild-Player von praktisch
// jeder Stelle nach unten wegziehen schließt ihn wieder — dieselbe
// Schwelle wie beim Mini-Player, damit kein zaghaftes Scrollen die
// Ansicht versehentlich zuklappt.
onVerticalDragEnd: (details) {
final geschwindigkeit = details.primaryVelocity ?? 0;
if (geschwindigkeit > wischSchwelle) Navigator.maybePop(context);
},
child: SafeArea(
child: StreamBuilder<MediaItem?>(
stream: handler.mediaItem,
builder: (context, snapshot) {
@@ -66,38 +97,201 @@ class NowPlayingScreen extends StatelessWidget {
if (item == null) {
return const Center(child: Text('Nichts in Wiedergabe'));
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
Expanded(child: Center(child: _Cover(item: item))),
const SizedBox(height: 24),
Text(
item.title,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
item.artist ?? 'Unbekannt',
style: const TextStyle(color: MeloTheme.text2),
),
const SizedBox(height: 24),
_ProgressBar(handler: handler),
const _Controls(),
const SizedBox(height: 16),
],
),
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,
),
),
],
),
);
},
);
},
),
),
),
),
);
}
}
/// Färbt den Grund des Players nach dem Coverbild ein.
///
/// Der Player sah für jeden Titel gleich aus — schwarz. Ein Verlauf aus der
/// prägenden Farbe des Covers gibt jedem Album sein eigenes Gesicht, ohne
/// dass die Schrift darunter leidet: die Farbe wird vorher so weit
/// abgedunkelt, dass Weiß darauf lesbar bleibt (siehe [lesbarAuf]).
class _CoverGrund extends StatefulWidget {
const _CoverGrund({required this.handler, required this.child});
final MeloAudioHandler handler;
final Widget child;
@override
State<_CoverGrund> createState() => _CoverGrundState();
}
class _CoverGrundState extends State<_CoverGrund> {
Color _farbe = MeloTheme.black;
Uri? _zuletzt;
StreamSubscription<MediaItem?>? _abo;
@override
void initState() {
super.initState();
_abo = widget.handler.mediaItem.listen(_uebernimm);
}
@override
void dispose() {
_abo?.cancel();
super.dispose();
}
Future<void> _uebernimm(MediaItem? item) async {
final art = item?.artUri;
// Nur bei einem echten Bildwechsel neu rechnen: `mediaItem` meldet auch
// nachgetragene Dauern, und jedes Mal ein Bild zu dekodieren wäre Unfug.
if (art == _zuletzt) return;
_zuletzt = art;
if (art == null) {
if (mounted) setState(() => _farbe = MeloTheme.black);
return;
}
final bild = await coverBildQuelle(art);
if (bild == null) {
if (mounted) setState(() => _farbe = MeloTheme.black);
return;
}
final farbe = lesbarAuf(await farbeAusBild(bild));
if (mounted && art == _zuletzt) setState(() => _farbe = farbe);
}
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: MeloMotion.ruhig(context, MeloMotion.normal),
curve: MeloMotion.curve,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
// Nach unten in Schwarz auslaufen: dort sitzen Fortschritt und
// Bedienung, und die stehen auf dem gewohnten Grund am ruhigsten.
colors: [_farbe, MeloTheme.black],
stops: const [0.0, 0.75],
),
),
child: widget.child,
);
}
}
/// Titel, Künstler, Fortschritt und Bedienung — der Teil, der im Querformat
/// neben das Cover rückt.
class _Angaben extends StatelessWidget {
const _Angaben({required this.item, required this.handler});
final MediaItem item;
final MeloAudioHandler handler;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.title,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: MeloSpace.sm),
Text(
item.artist ?? 'Unbekannt',
style: const TextStyle(color: MeloTheme.text2),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: MeloSpace.lg),
_ProgressBar(handler: handler),
const _Controls(),
const SizedBox(height: MeloSpace.md),
],
);
}
}
/// Das passende Herz für den laufenden Titel.
class _Herz extends StatelessWidget {
const _Herz({required this.item});
final MediaItem item;
@override
Widget build(BuildContext context) {
final songId = songIdOf(item);
if (songId != null && songId.isNotEmpty) {
return FavoriteButton(songId: songId);
}
final navId = navidromeIdOf(item);
if (navId == null) return const SizedBox.shrink();
return ServerFavoriteButton(
// Ohne Schlüssel behielte das Herz beim Titelwechsel seinen Zustand:
// Flutter würde dasselbe Element weiterverwenden, und der nächste Titel
// erschiene als Favorit, obwohl er nie einer war.
key: ValueKey(navId),
navidromeId: navId,
navidrome: context.read<MeloAudioHandler>().navidrome,
);
}
}
/// 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;
@@ -106,7 +300,10 @@ class _Cover extends StatelessWidget {
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1,
child: CoverImage(artUri: item.artUri, radius: 16),
child: Hero(
tag: coverHeldenName,
child: CoverImage(artUri: item.artUri, radius: 16),
),
);
}
}
@@ -268,6 +465,12 @@ class _SleepTimerButton extends StatelessWidget {
? const SizedBox.shrink()
: ListTile(
title: const Text('Timer beenden'),
// Ohne die Restzeit hier war sie faktisch unsichtbar —
// die einzige andere Stelle ist der Tooltip des
// AppBar-Icons, den kaum jemand entdeckt.
subtitle: Text(
'Noch ${remaining.inMinutes}:'
'${(remaining.inSeconds % 60).toString().padLeft(2, '0')}'),
leading: const Icon(Icons.close),
onTap: () {
handler.sleepTimer.cancel();
@@ -317,7 +520,10 @@ class _LyricsSheet extends StatefulWidget {
class _LyricsSheetState extends State<_LyricsSheet> {
late final NavidromeService _nav = NavidromeService();
String? _text;
List<LyricZeile> _zeilen = const [];
bool _loading = true;
final _scroll = ScrollController();
int? _gezeigteZeile;
@override
void initState() {
@@ -325,29 +531,62 @@ class _LyricsSheetState extends State<_LyricsSheet> {
_load();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
/// Zuerst der Songtext aus dem Tag der Datei — der ist sofort da und
/// funktioniert offline. Nur wenn keiner drinsteht, wird der Server gefragt.
Future<void> _load() async {
final db = context.read<MeloDb>();
final lokal = await db.lyricsOf(widget.songId);
if (lokal != null) {
if (mounted) setState(() { _text = lokal; _loading = false; });
if (mounted) setState(() => _uebernimm(lokal));
return;
}
await _nav.ladeGespeicherteZugangsdaten();
if (_nav.istVerbunden) {
final lyrics = await _nav.getLyrics(widget.songId);
if (mounted) {
setState(() {
_text = lyrics.isEmpty ? null : lyrics.text;
_loading = false;
});
setState(() => _uebernimm(lyrics.isEmpty ? null : lyrics.text));
}
return;
}
if (mounted) setState(() => _loading = false);
}
void _uebernimm(String? text) {
_text = text;
// Trägt der Text Zeitmarken, läuft er mit; sonst bleibt es eine
// Textwand — beides kommt vor, je nach Tag und Server.
_zeilen = text == null ? const [] : parseLrc(text);
_loading = false;
}
/// Schiebt die aktive Zeile in die Mitte. Nur beim Zeilenwechsel, nicht bei
/// jeder Positionsmeldung — sonst ruckelt die Ansicht permanent.
void _folge(int index) {
if (_gezeigteZeile == index || !_scroll.hasClients) return;
_gezeigteZeile = index;
final ziel = (index * _zeilenHoehe) -
(_scroll.position.viewportDimension / 2) +
(_zeilenHoehe / 2);
final wohin = ziel.clamp(0.0, _scroll.position.maxScrollExtent);
final dauer = MeloMotion.ruhig(context, MeloMotion.normal);
// Bei abgeschalteten Systemanimationen wird die Dauer null — `animateTo`
// wirft dann eine Zusicherung. Ohne Bewegung ist Springen ohnehin das,
// was die Einstellung meint.
if (dauer == Duration.zero) {
_scroll.jumpTo(wohin);
return;
}
_scroll.animateTo(wohin, duration: dauer, curve: MeloMotion.curve);
}
static const _zeilenHoehe = 40.0;
@override
Widget build(BuildContext context) {
return Container(
@@ -355,43 +594,112 @@ class _LyricsSheetState extends State<_LyricsSheet> {
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(MeloSpace.md),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('📝 Songtext',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
Text(
_zeilen.isEmpty ? 'Songtext' : 'Songtext · läuft mit',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w600),
),
IconButton(
tooltip: 'Schließen',
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
Expanded(
child: _loading
? const Center(
child: CircularProgressIndicator(color: MeloTheme.red),
)
: _text == null
? const Center(
child: Text('Kein Songtext verfügbar',
style: TextStyle(color: MeloTheme.text2)),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Text(
_text!,
style: const TextStyle(
fontSize: 14,
height: 1.6,
color: Colors.white,
),
),
),
),
Expanded(child: _inhalt()),
],
),
);
}
Widget _inhalt() {
if (_loading) {
return const Center(
child: CircularProgressIndicator(color: MeloTheme.red),
);
}
final text = _text;
if (text == null) {
return const Center(
child: Text('Kein Songtext verfügbar',
style: TextStyle(color: MeloTheme.text2)),
);
}
if (_zeilen.isEmpty) {
return SingleChildScrollView(
padding: const EdgeInsets.all(MeloSpace.md),
child: Text(
text,
style: const TextStyle(fontSize: 14, height: 1.6, color: Colors.white),
),
);
}
return _Mitlaufend(
zeilen: _zeilen,
scroll: _scroll,
zeilenHoehe: _zeilenHoehe,
onZeile: _folge,
);
}
}
/// Die mitlaufende Darstellung: aktive Zeile hell und hervorgehoben, der
/// Rest gedämpft. Antippen springt an die Stelle im Titel.
class _Mitlaufend extends StatelessWidget {
const _Mitlaufend({
required this.zeilen,
required this.scroll,
required this.zeilenHoehe,
required this.onZeile,
});
final List<LyricZeile> zeilen;
final ScrollController scroll;
final double zeilenHoehe;
final ValueChanged<int> onZeile;
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
return StreamBuilder<Duration>(
stream: handler.positionStream,
builder: (context, snapshot) {
final aktiv = aktiveZeile(zeilen, snapshot.data ?? Duration.zero);
if (aktiv != null) {
WidgetsBinding.instance.addPostFrameCallback((_) => onZeile(aktiv));
}
return ListView.builder(
controller: scroll,
itemExtent: zeilenHoehe,
padding: const EdgeInsets.symmetric(horizontal: MeloSpace.md),
itemCount: zeilen.length,
itemBuilder: (context, i) {
final istAktiv = i == aktiv;
return InkWell(
onTap: () => handler.seek(zeilen[i].zeit),
child: Align(
alignment: Alignment.centerLeft,
child: AnimatedDefaultTextStyle(
duration: MeloMotion.ruhig(context, MeloMotion.fast),
style: TextStyle(
fontSize: istAktiv ? 17 : 15,
height: 1.3,
fontWeight: istAktiv ? FontWeight.w700 : FontWeight.w400,
color: istAktiv ? Colors.white : MeloTheme.text3,
),
child: Text(zeilen[i].text,
maxLines: 1, overflow: TextOverflow.ellipsis),
),
),
);
},
);
},
);
}
}
+93 -88
View File
@@ -3,15 +3,16 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../shared/cover.dart';
import '../shared/lauf_balken.dart';
import '../shared/theme.dart';
import 'audio_handler.dart';
/// Warteschlangen-Ansicht: aktuelle Queue anzeigen, zu einem Titel springen,
/// Titel entfernen oder per Drag & Drop umsortieren.
///
/// Der `MeloAudioHandler` bietet keine Live-Queue-Mutation (kein
/// `removeQueueItem`/`moveQueueItem`), daher wird die Queue clientseitig neu
/// berechnet und per `loadPlaylist` neu geladen.
/// Der `MeloAudioHandler` mutiert die Warteschlange direkt
/// ([MeloAudioHandler.removeQueueItemAt], [MeloAudioHandler.moveQueueItem]) —
/// die laufende Wiedergabe bleibt dabei unangetastet.
class QueueScreen extends StatelessWidget {
const QueueScreen({super.key});
@@ -37,32 +38,15 @@ class QueueScreen extends StatelessWidget {
return ReorderableListView.builder(
itemCount: queue.length,
onReorderItem: (oldIndex, newIndex) =>
_reorder(handler, oldIndex, newIndex),
itemBuilder: (context, i) {
final item = queue[i];
final playing = item.id == currentId;
return ListTile(
key: ValueKey(item.id),
leading: CoverImage(artUri: item.artUri, size: 48, radius: 6),
title: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: playing
? const TextStyle(
color: MeloTheme.red, fontWeight: FontWeight.bold)
: null,
),
subtitle: Text(item.artist ?? 'Unbekannt',
maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: IconButton(
tooltip: 'Aus Warteschlange entfernen',
icon: const Icon(Icons.close),
onPressed: () => _remove(handler, i),
),
onTap: () => handler.skipToQueueItem(i),
);
},
handler.moveQueueItem(oldIndex, newIndex),
itemBuilder: (context, i) => _Zeile(
key: ValueKey(queue[i].id),
index: i,
item: queue[i],
laeuft: queue[i].id == currentId,
onSpringen: () => handler.skipToQueueItem(i),
onEntfernen: () => handler.removeQueueItemAt(i),
),
);
},
);
@@ -70,68 +54,89 @@ class QueueScreen extends StatelessWidget {
),
);
}
}
Future<void> _remove(MeloAudioHandler handler, int index) async {
final queue = handler.queue.valueOrNull ?? const <MediaItem>[];
if (index < 0 || index >= queue.length) return;
final playingId = handler.mediaItem.valueOrNull?.id;
final position = await handler.positionStream.first;
/// Eine Zeile der Warteschlange.
///
/// Eigene Klasse, damit der Wiedergabe-Zustand (läuft / pausiert) hier
/// beobachtet wird statt in einem dritten `StreamBuilder`, der die ganze
/// Liste umschließt und bei jedem Pausieren neu aufbaut.
class _Zeile extends StatelessWidget {
const _Zeile({
super.key,
required this.index,
required this.item,
required this.laeuft,
required this.onSpringen,
required this.onEntfernen,
});
final newQueue = removeFromQueue(queue, index);
if (newQueue.isEmpty) {
await handler.stop();
return;
}
final fallbackIndex = index.clamp(0, newQueue.length - 1);
final newIndex = newPlayingIndex(newQueue, playingId, fallbackIndex);
await handler.loadPlaylist(newQueue, startIndex: newIndex);
if (newQueue[newIndex].id == playingId) {
await handler.seek(position);
}
/// Position in der Warteschlange — nötig für das Drag-Handle.
final int index;
final MediaItem item;
/// Dieser Eintrag steht in der Wiedergabe — auch pausiert.
final bool laeuft;
final VoidCallback onSpringen;
final VoidCallback onEntfernen;
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
// Nur die laufende Zeile hört wirklich zu — der Zustand meldet auch den
// Pufferstand und feuert mehrmals pro Sekunde. Der StreamBuilder bleibt
// aber stehen, damit die Zeile beim Titelwechsel nicht ihre Form ändert
// und dabei ihr Coverbild neu laden muss.
return StreamBuilder<PlaybackState>(
stream: laeuft ? handler.playbackState : null,
initialData: handler.playbackState.valueOrNull,
builder: (context, zustandSnap) =>
_kachel(spielt: laeuft && (zustandSnap.data?.playing ?? false)),
);
}
Future<void> _reorder(
MeloAudioHandler handler, int oldIndex, int newIndex) async {
final queue = handler.queue.valueOrNull ?? const <MediaItem>[];
if (queue.isEmpty) return;
final playingId = handler.mediaItem.valueOrNull?.id;
final position = await handler.positionStream.first;
final newQueue = reorderQueue(queue, oldIndex, newIndex);
final fallbackIndex = newIndex.clamp(0, newQueue.length - 1);
final newPlayIndex = newPlayingIndex(newQueue, playingId, fallbackIndex);
await handler.loadPlaylist(newQueue, startIndex: newPlayIndex);
if (newQueue[newPlayIndex].id == playingId) {
await handler.seek(position);
}
Widget _kachel({required bool spielt}) {
return ListTile(
// Dasselbe Zeichen wie in der Bibliothek: Balken über dem Cover,
// damit man dieselbe Stelle wiedererkennt.
leading: LaufMarkierung(
markiert: laeuft,
spielt: spielt,
radius: 6,
child: CoverImage(artUri: item.artUri, size: 48, radius: 6),
),
title: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: laeuft
? const TextStyle(
color: MeloTheme.red, fontWeight: FontWeight.bold)
: null,
),
subtitle: Text(item.artist ?? 'Unbekannt',
maxLines: 1, overflow: TextOverflow.ellipsis),
// Ohne sichtbares Handle war Umsortieren nur per unmarkiertem
// Long-Press-and-Drag irgendwo auf der Zeile auffindbar.
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Aus Warteschlange entfernen',
icon: const Icon(Icons.close),
onPressed: onEntfernen,
),
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Icon(Icons.drag_handle, color: MeloTheme.text3),
),
),
],
),
onTap: onSpringen,
);
}
}
// === Reine Queue-Logik (ohne AudioHandler testbar) ===
/// Entfernt den Titel an [removeIndex] aus [queue].
List<MediaItem> removeFromQueue(List<MediaItem> queue, int removeIndex) {
return List<MediaItem>.from(queue)..removeAt(removeIndex);
}
/// Verschiebt den Titel von [oldIndex] nach [newIndex]. `newIndex` ist bereits
/// um das entfernte Element bereinigt (siehe `ReorderableListView.onReorderItem`).
List<MediaItem> reorderQueue(List<MediaItem> queue, int oldIndex, int newIndex) {
final result = List<MediaItem>.from(queue);
final moved = result.removeAt(oldIndex);
result.insert(newIndex, moved);
return result;
}
/// Ermittelt den Index des zuvor abgespielten Titels ([playingId]) in
/// [newQueue]. Ist er nicht mehr vorhanden, wird auf [fallbackIndex]
/// zurückgefallen (z.B. der Titel, der jetzt an seiner alten Position steht).
int newPlayingIndex(
List<MediaItem> newQueue, String? playingId, int fallbackIndex) {
if (newQueue.isEmpty) return 0;
if (playingId != null) {
final idx = newQueue.indexWhere((item) => item.id == playingId);
if (idx != -1) return idx;
}
return fallbackIndex.clamp(0, newQueue.length - 1);
}
+50
View File
@@ -0,0 +1,50 @@
/// Was eine waagerechte Wischbewegung am Mini-Player bewirkt.
enum Wechsel { vorheriger, naechster }
/// Ab welcher Wisch-Geschwindigkeit (Pixel pro Sekunde) eine Geste zählt.
///
/// Bewusst nicht null: der Mini-Player ist zugleich ein Knopf, und beim
/// Antippen wandert der Finger fast immer ein paar Pixel. Ohne Schwelle
/// spränge bei jedem zweiten Tipp der Titel.
const wischSchwelle = 300.0;
/// Zu welchem Titel eine waagerechte Bewegung führt, oder `null`, wenn sie
/// zu zaghaft war.
///
/// Nach links wischen holt den nächsten Titel herein — so, wie man ein Blatt
/// zur Seite schiebt und das darunter sichtbar wird.
Wechsel? titelWechsel(double geschwindigkeit) {
if (geschwindigkeit.abs() <= wischSchwelle) return null;
return geschwindigkeit < 0 ? Wechsel.naechster : Wechsel.vorheriger;
}
/// Ob eine senkrechte Bewegung die Vollbild-Wiedergabe öffnet.
/// Nur nach oben — nach unten ist bewusst unbelegt, damit niemand die
/// Wiedergabe aus Versehen wegschiebt.
bool oeffnetVollbild(double geschwindigkeit) =>
geschwindigkeit < -wischSchwelle;
/// Ab welcher zurückgelegten Strecke (in Pixeln) eine langsame, aber
/// eindeutige Wischbewegung auch ohne Schwung zählt.
///
/// Ohne diese Schwelle bekam ein bedächtiges, aber weites Wischen (unter
/// [wischSchwelle] Pixel pro Sekunde) überhaupt keine Reaktion — für den
/// Nutzer sah das aus wie ein nicht reagierender Bildschirm statt "zu
/// langsam gewischt".
const wischStreckeSchwelle = 80.0;
/// Wie [titelWechsel], aber eine ausreichend weite Bewegung zählt zusätzlich
/// auch bei geringem Schwung.
Wechsel? titelWechselMitStrecke(double strecke, double geschwindigkeit) {
final ausSchwung = titelWechsel(geschwindigkeit);
if (ausSchwung != null) return ausSchwung;
if (strecke.abs() <= wischStreckeSchwelle) return null;
return strecke < 0 ? Wechsel.naechster : Wechsel.vorheriger;
}
/// Wie [oeffnetVollbild], aber eine ausreichend weite Bewegung nach oben
/// zählt zusätzlich auch bei geringem Schwung.
bool oeffnetVollbildMitStrecke(double strecke, double geschwindigkeit) {
if (oeffnetVollbild(geschwindigkeit)) return true;
return strecke < -wischStreckeSchwelle;
}
+99 -43
View File
@@ -1,3 +1,4 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -8,6 +9,7 @@ import '../library/song_media.dart';
import '../player/audio_handler.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart';
import '../shared/lauf_balken.dart';
import '../shared/theme.dart';
/// Playlist-Detailansicht: Songs anzeigen, per Drag & Drop umsortieren, entfernen.
@@ -18,10 +20,7 @@ class PlaylistDetailScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
final handler = context.read<MeloAudioHandler>();
final service = context.read<PlaylistService>();
final categories = context.watch<CategoryService>();
final settings = context.watch<AppSettings>();
return Scaffold(
appBar: AppBar(title: Text(playlist.name)),
body: StreamBuilder<List<Song>>(
@@ -48,49 +47,106 @@ class PlaylistDetailScreen extends StatelessWidget {
reordered.insert(newIndex, moved);
service.reorderAll(playlist.id, reordered.map((s) => s.id).toList());
},
itemBuilder: (context, i) {
final s = songs[i];
return ListTile(
key: ValueKey(s.id),
leading: Builder(builder: (context) {
final cover = categories.coverFor(s,
groupByCategory: settings.groupCoversByCategory);
return CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
);
}),
title: Text(s.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(s.artist ?? 'Unbekannt',
maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: IconButton(
tooltip: 'Aus Playlist entfernen',
icon: const Icon(Icons.remove_circle_outline),
onPressed: () => service.removeSongFromPlaylist(playlist.id, s.id),
),
onTap: () async {
try {
await playSongs(
handler,
songs,
i,
coverOf: (song) => categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory),
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
}
},
);
},
itemBuilder: (context, i) => _Zeile(
key: ValueKey(songs[i].id),
song: songs[i],
alleSongs: songs,
index: i,
onEntfernen: () =>
service.removeSongFromPlaylist(playlist.id, songs[i].id),
),
);
},
),
);
}
}
/// Eine Zeile der Wiedergabeliste.
///
/// Nicht [SongZeile]: hier steht rechts „Aus Playlist entfernen" statt Herz
/// und Menü. Die Lauf-Markierung ist dieselbe — der Nutzer soll dasselbe
/// Zeichen sehen wie in der Bibliothek.
class _Zeile extends StatelessWidget {
const _Zeile({
super.key,
required this.song,
required this.alleSongs,
required this.index,
required this.onEntfernen,
});
final Song song;
final List<Song> alleSongs;
final int index;
final VoidCallback onEntfernen;
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
final categories = context.watch<CategoryService>();
final settings = context.watch<AppSettings>();
final cover = categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory);
return StreamBuilder<MediaItem?>(
stream: handler.mediaItem,
initialData: handler.mediaItem.valueOrNull,
builder: (context, laufSnap) {
final markiert = istAmLaufen(laufSnap.data, song.id);
return StreamBuilder<PlaybackState>(
stream: markiert ? handler.playbackState : null,
initialData: handler.playbackState.valueOrNull,
builder: (context, zustandSnap) => ListTile(
leading: LaufMarkierung(
markiert: markiert,
spielt: markiert && (zustandSnap.data?.playing ?? false),
radius: 6,
child: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
),
),
title: Text(
song.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: markiert
? const TextStyle(
color: MeloTheme.red, fontWeight: FontWeight.w600)
: null,
),
subtitle: Text(song.artist ?? 'Unbekannt',
maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: IconButton(
tooltip: 'Aus Playlist entfernen',
icon: const Icon(Icons.remove_circle_outline),
onPressed: onEntfernen,
),
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
try {
final uebersprungen = await playSongs(
handler,
alleSongs,
index,
coverOf: (s) => categories.coverFor(s,
groupByCategory: settings.groupCoversByCategory),
);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
);
}
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
},
),
);
},
);
}
}
+15 -8
View File
@@ -3,7 +3,8 @@ import 'package:provider/provider.dart';
import '../library/database.dart';
import '../library/playlist_service.dart';
import '../library/song_list.dart';
import '../shared/sort_store.dart';
import '../shared/sortable_song_list.dart';
import '../shared/theme.dart';
import 'create_playlist_dialog.dart';
import 'playlist_detail_screen.dart';
@@ -146,6 +147,10 @@ class _Empty extends StatelessWidget {
}
/// Gepushte Ansicht der favorisierten Songs.
///
/// Dieselbe [SortableSongList] wie im Favoriten-Tab (Shuffle, Sortieren,
/// Mehrfachauswahl) — vorher stand hier eine nackte [SongList] ohne all das,
/// obwohl es dieselben Songs sind wie im Tab.
class FavoritesListScreen extends StatelessWidget {
const FavoritesListScreen({super.key});
@@ -157,13 +162,15 @@ class FavoritesListScreen extends StatelessWidget {
body: StreamBuilder<List<Song>>(
stream: db.watchFavorites(),
builder: (context, snapshot) {
final songs = snapshot.data ?? const [];
if (songs.isEmpty) {
return const Center(
child: Text('Noch keine Favoriten', style: TextStyle(color: MeloTheme.text2)),
);
}
return SongList(songs);
final songs = snapshot.data ?? const <Song>[];
return SortableSongList(
songs: songs,
storeKey: SortStore.favoriten,
empty: const Center(
child: Text('Noch keine Favoriten',
style: TextStyle(color: MeloTheme.text2)),
),
);
},
),
);
+28
View File
@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'navidrome_service.dart';
/// Ablage für den Anmelde-Token. Als Schnittstelle, damit Tests ohne die
/// Plattform-Kanäle von FlutterSecureStorage auskommen.
abstract class TokenSpeicher {
@@ -100,6 +102,15 @@ class BakaAuth extends ChangeNotifier {
'Anmeldung fehlgeschlagen (${antwort.statusCode})';
}
/// Meldet automatisch an, falls noch nicht angemeldet — für Server-User
/// (Dustin, Baka, Tinker), die keine eigene Baka-Anmeldung mehr sehen
/// sollen: der Proxy nutzt seine YouTube-Cookies für jeden
/// authentifizierten Aufruf ohnehin automatisch.
Future<void> autoAnmelden(String benutzer, String passwort) async {
if (istAngemeldet || benutzer.isEmpty || passwort.isEmpty) return;
await anmelden(benutzer, passwort);
}
Future<void> abmelden() async {
_token = null;
_benutzer = '';
@@ -108,3 +119,20 @@ class BakaAuth extends ChangeNotifier {
notifyListeners();
}
}
/// Prüft, ob ein Server-User verbunden ist, und meldet ihn dann automatisch
/// beim Baka-Konto an — geteilte Logik für den YouTube-Bereich
/// (downloads_screen.dart) und die YT-Suche (youtube_search_screen.dart).
/// Gäste (kein Server-Account) bleiben unangetastet. Schlägt die
/// Hintergrund-Anmeldung fehl (falsche/fehlende Zugangsdaten, Server nicht
/// erreichbar), meldet [autoLoginFehlgeschlagen] das zurück — sonst bliebe
/// so ein User ohne jeden Weg mehr zur manuellen Baka-Anmeldung.
Future<({bool serverUser, bool autoLoginFehlgeschlagen})> pruefeServerUser(
NavidromeService navidrome, BakaAuth auth) async {
await navidrome.ladeGespeicherteZugangsdaten();
if (!navidrome.istVerbunden) {
return (serverUser: false, autoLoginFehlgeschlagen: false);
}
await auth.autoAnmelden(navidrome.benutzer, navidrome.passwort);
return (serverUser: true, autoLoginFehlgeschlagen: !auth.istAngemeldet);
}
+117 -3
View File
@@ -3,6 +3,63 @@ import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
/// Ein Eintrag des Zwischenspeichers, wie ihn die Verdrängung sieht.
/// Ohne Datei-Zugriff, damit [zuVerdraengen] rein bleibt.
class CacheEintrag {
const CacheEintrag({
required this.pfad,
required this.groesse,
required this.geaendertMs,
});
final String pfad;
final int groesse;
/// Wann die Datei zuletzt angefasst wurde. Bewusst „geändert" und nicht
/// „zugegriffen": Android hängt Datenträger meist mit `noatime` ein, die
/// Zugriffszeit steht dort still. Der Cache-Treffer stempelt die Datei
/// deshalb selbst um (siehe [CacheManager.getCachedFile]).
final int geaendertMs;
}
/// Welche Dateien weichen müssen, damit der Zwischenspeicher [maxBytes] nicht
/// überschreitet — am längsten nicht gehört zuerst.
///
/// Rein und ohne Dateisystem: die Reihenfolge ist der Teil, der schiefgehen
/// kann, und der lässt sich so für sich prüfen.
/// [geschuetzt] sind Pfade, die gerade als Quelle in der Warteschlange
/// stehen. Sie bleiben liegen, auch wenn die Grenze dadurch überschritten
/// bleibt: die Wiedergabe hat die Pfade schon fest eingetragen, und eine
/// gelöschte Datei ließe sie mitten im Album abbrechen — ohne Rückfall aufs
/// Streamen. Die Grenze wird beim nächsten Mal wieder eingehalten.
List<String> zuVerdraengen(
List<CacheEintrag> eintraege,
int maxBytes, {
Set<String> geschuetzt = const {},
}) {
final grenze = maxBytes < 0 ? 0 : maxBytes;
var belegt = eintraege.fold<int>(0, (summe, e) => summe + e.groesse);
if (belegt <= grenze) return const [];
final sortiert = [
for (final e in eintraege)
if (!geschuetzt.contains(e.pfad)) e,
]..sort((a, b) {
final zeit = a.geaendertMs.compareTo(b.geaendertMs);
// Gleicher Zeitstempel: nach Pfad, damit dasselbe Eingabebild immer
// dieselbe Antwort ergibt.
return zeit != 0 ? zeit : a.pfad.compareTo(b.pfad);
});
final weg = <String>[];
for (final eintrag in sortiert) {
if (belegt <= grenze) break;
weg.add(eintrag.pfad);
belegt -= eintrag.groesse;
}
return weg;
}
class CacheManager {
static const _cacheDir = 'melo_cache';
late Directory _cacheFolder;
@@ -42,9 +99,7 @@ class CacheManager {
try {
final hash = md5.convert(streamUrl.codeUnits).toString();
final file = File('${_cacheFolder.path}/$hash.mp3');
if (await file.exists()) {
return file;
}
if (await file.exists()) return file;
return null;
} catch (e) {
debugPrint('Fehler beim Abrufen der Cache-Datei: $e');
@@ -52,6 +107,65 @@ class CacheManager {
}
}
/// Stempelt die Datei auf jetzt um — erst dadurch weiß die Verdrängung,
/// was zuletzt **gehört** wurde. Die Zugriffszeit des Dateisystems taugt
/// dafür nicht (siehe [CacheEintrag.geaendertMs]).
///
/// Bewusst getrennt vom Nachschlagen: beim Aufbau einer Warteschlange wird
/// jede Datei einmal nachgeschlagen. Würde dabei umgestempelt, hätten alle
/// fast denselben Zeitstempel und die Verdrängung wüsste nicht mehr, was
/// wirklich gehört wurde.
Future<void> beruehre(String streamUrl) async {
try {
final hash = md5.convert(streamUrl.codeUnits).toString();
final file = File('${_cacheFolder.path}/$hash.mp3');
if (await file.exists()) await file.setLastModified(DateTime.now());
} catch (e) {
debugPrint('Cache-Zeitstempel nicht setzbar: $e');
}
}
/// Alle Einträge mit Größe und Zeitstempel — Grundlage für [zuVerdraengen].
Future<List<CacheEintrag>> eintraege() async {
if (!await _cacheFolder.exists()) return const [];
final gefunden = <CacheEintrag>[];
for (final eintrag in _cacheFolder.listSync()) {
if (eintrag is! File) continue;
// Halbe Downloads zählen nicht mit; sie räumt der Downloader selbst weg.
if (eintrag.path.endsWith('.part')) continue;
try {
final status = eintrag.statSync();
gefunden.add(CacheEintrag(
pfad: eintrag.path,
groesse: status.size,
geaendertMs: status.modified.millisecondsSinceEpoch,
));
} catch (e) {
debugPrint('Cache-Eintrag nicht lesbar: $e');
}
}
return gefunden;
}
/// Hält den Zwischenspeicher unter [maxBytes] und meldet, wie viele Dateien
/// dafür weichen mussten. Die bewusst heruntergeladenen Titel liegen in
/// einem anderen Ordner und sind davon nie betroffen.
Future<int> begrenzeAuf(int maxBytes, {Set<String> geschuetzt = const {}}) async {
final weg =
zuVerdraengen(await eintraege(), maxBytes, geschuetzt: geschuetzt);
var geloescht = 0;
for (final pfad in weg) {
try {
await File(pfad).delete();
geloescht++;
} catch (e) {
debugPrint('Cache-Eintrag nicht löschbar: $e');
}
}
if (geloescht > 0) debugPrint('Zwischenspeicher: $geloescht Dateien verdrängt');
return geloescht;
}
Future<File> getCacheFile(String streamUrl) async {
final hash = md5.convert(streamUrl.codeUnits).toString();
return File('${_cacheFolder.path}/$hash.mp3');
+106
View File
@@ -0,0 +1,106 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
/// Legt Coverbilder vom Server auf der Platte ab.
///
/// `Image.network` merkt sich Bilder nur im Arbeitsspeicher. Beim nächsten
/// Kaltstart fragte deshalb jede sichtbare Zeile ihr Cover erneut beim Server
/// an — und ohne Netz gab es gar keins, obwohl das Bild schon dreimal geladen
/// worden war.
///
/// Absichtlich im Cache-Ordner und nicht bei den Downloads: ein Cover ist
/// ersetzbar. Räumt Android den Ordner, lädt es sich beim nächsten Mal neu.
class CoverCache {
static const ordnerName = 'melo_covers';
/// Höchstzahl gemerkter Abfragen. Ohne Deckel wüchse die Tabelle mit jedem
/// je gesehenen Cover; die Dateien bleiben davon unberührt.
static const _merkGrenze = 500;
/// Laufende und fertige Abfragen je Adresse.
///
/// Der **Future** wird gemerkt, nicht erst das Ergebnis: `CoverImage` ist
/// zustandslos und fragt bei jedem Neuzeichnen. Ohne diese Tabelle liefe
/// derselbe Download mehrfach parallel.
static final Map<String, Future<File?>> _abfragen = {};
static Directory? _ordner;
/// Macht den Namen der Zwischendatei je Abruf eindeutig.
static int _laufendeNummer = 0;
static Future<Directory> _ordnerHolen() async {
final vorhanden = _ordner;
if (vorhanden != null) return vorhanden;
final basis = await getApplicationCacheDirectory();
final ordner = Directory(p.join(basis.path, ordnerName));
if (!await ordner.exists()) await ordner.create(recursive: true);
_ordner = ordner;
return ordner;
}
/// Dateiname einer Cover-Adresse.
///
/// Ohne die Zugangsdaten: die Adresse trägt bei jedem App-Start ein neues
/// Token und Salt. Als Schlüssel wäre dasselbe Bild sonst jedes Mal ein
/// anderes, und der Ordner wüchse, ohne je einen Treffer zu liefern.
static String dateiName(Uri adresse) {
final id = adresse.queryParameters['id'] ?? adresse.path;
return '${md5.convert(id.codeUnits)}.img';
}
/// Die Bilddatei zu [adresse] — aus dem Ordner, sonst frisch geladen.
/// `null`, wenn es sie nicht gibt und auch nicht zu holen ist.
static Future<File?> hole(Uri adresse) {
final schluessel = dateiName(adresse);
final bekannt = _abfragen[schluessel];
if (bekannt != null) return bekannt;
if (_abfragen.length >= _merkGrenze) _abfragen.clear();
// Einen Fehlschlag nicht dauerhaft merken: startet die App ohne Netz,
// blieben sonst alle Cover bis zum Neustart grau, auch wenn die
// Verbindung längst wieder steht.
final abfrage = _hole(adresse, schluessel).then((datei) {
if (datei == null) _abfragen.remove(schluessel);
return datei;
});
_abfragen[schluessel] = abfrage;
return abfrage;
}
static Future<File?> _hole(Uri adresse, String schluessel) async {
try {
final datei = File(p.join((await _ordnerHolen()).path, schluessel));
if (await datei.exists()) return datei;
final antwort =
await http.get(adresse).timeout(const Duration(seconds: 15));
if (antwort.statusCode != 200 || antwort.bodyBytes.isEmpty) return null;
// Subsonic meldet Fehler mit HTTP 200 und JSON — das wäre als Bild
// unbrauchbar und läge dann dauerhaft im Ordner.
final typ = antwort.headers['content-type'] ?? '';
if (!typ.startsWith('image/')) return null;
// Erst vollständig schreiben, dann umbenennen: ein Abbruch hinterlässt
// sonst eine halbe Datei, die als gültiger Treffer gälte. Der
// Zwischenname ist je Abruf eindeutig — zwei gleichzeitige Abrufe
// desselben Covers schrieben sonst in dieselbe Datei und könnten ein
// zerschnittenes Bild hinterlassen, das dauerhaft liegen bliebe.
final teil = File('${datei.path}.${_laufendeNummer++}.part');
await teil.writeAsBytes(antwort.bodyBytes, flush: true);
await teil.rename(datei.path);
return datei;
} catch (e) {
debugPrint('Cover nicht ladbar: $e');
return null;
}
}
/// Nur für Tests: vergisst die gemerkten Abfragen.
@visibleForTesting
static void vergissAbfragen() => _abfragen.clear();
}
+187
View File
@@ -0,0 +1,187 @@
import 'package:drift/drift.dart' show Value;
import 'package:flutter/foundation.dart';
import '../library/database.dart';
import 'download_speicher.dart';
import 'navidrome_service.dart';
/// Ab wie vielen Titeln vor dem Herunterladen nachgefragt wird.
///
/// Ein Album sind zehn bis zwanzig Titel — da wäre eine Rückfrage nur im Weg.
/// Ein Künstler können mehrere hundert und einige Gigabyte sein, und das
/// sollte niemand aus Versehen antippen.
const rueckfrageAb = 30;
bool brauchtRueckfrage(int anzahl) => anzahl >= rueckfrageAb;
/// Grobe Größenschätzung für die Rückfrage. Genau geht nicht — der Server
/// verrät die Dateigröße erst beim Abruf; diese Zahl beruht auf der Dauer
/// bei rund 256 kbit/s und ist als Hausnummer gedacht.
int geschaetzteBytes(Iterable<int> dauernSekunden) =>
dauernSekunden.fold<int>(0, (summe, s) => summe + s * 32000);
enum _Ergebnis { neu, schonDa, fehler }
/// Lädt Server-Titel bewusst auf das Gerät und führt Buch darüber.
///
/// Getrennt vom Zwischenspeicher, der beim Abspielen nebenbei entsteht: was
/// hier landet, hat der Nutzer gewollt und wird nie von selbst verdrängt.
class DownloadService extends ChangeNotifier {
DownloadService({
required this.db,
required this.navidrome,
DownloadSpeicher? speicher,
}) : speicher = speicher ?? DownloadSpeicher();
final MeloDb db;
final NavidromeService navidrome;
final DownloadSpeicher speicher;
bool _laeuft = false;
int _fertig = 0;
int _gesamt = 0;
int _fehlgeschlagen = 0;
String? _aktuell;
String? _fehler;
bool _abbruchGewuenscht = false;
bool get laeuft => _laeuft;
int get fertig => _fertig;
int get gesamt => _gesamt;
/// Titel, der gerade geladen wird.
String? get aktuell => _aktuell;
/// Meldung des letzten Fehlschlags. Sagt **nicht**, dass der ganze Lauf
/// gescheitert ist — siehe [fehlgeschlagen].
String? get fehler => _fehler;
/// Wie viele Titel im letzten Lauf nicht geladen werden konnten.
int get fehlgeschlagen => _fehlgeschlagen;
/// Fortschritt zwischen 0 und 1, oder `null`, solange nichts läuft.
double? get anteil =>
_laeuft && _gesamt > 0 ? (_fertig / _gesamt).clamp(0.0, 1.0) : null;
void abbrechen() {
if (_laeuft) _abbruchGewuenscht = true;
}
/// Lädt [songs] herunter. Bereits vorhandene Titel werden übersprungen.
/// Gibt zurück, wie viele neu dazugekommen sind.
Future<int> lade(List<SubsonicSong> songs) async {
if (_laeuft) return 0;
// Die Zugangsdaten können sich seit dem App-Start geändert haben: wer
// sich erst in den Einstellungen anmeldet, hätte hier sonst eine leere
// Verbindung und jeder Download liefe ins Leere.
if (!navidrome.istVerbunden) {
await navidrome.ladeGespeicherteZugangsdaten();
}
if (!navidrome.istVerbunden) {
_fehler = 'Nicht mit dem Musikserver verbunden';
notifyListeners();
return 0;
}
_laeuft = true;
_abbruchGewuenscht = false;
_fehler = null;
_fehlgeschlagen = 0;
_fertig = 0;
_gesamt = songs.length;
notifyListeners();
var neu = 0;
try {
for (final song in songs) {
if (_abbruchGewuenscht) break;
_aktuell = song.titel;
notifyListeners();
final ergebnis = await _ladeEinen(song);
if (ergebnis == _Ergebnis.neu) neu++;
if (ergebnis == _Ergebnis.fehler) _fehlgeschlagen++;
_fertig++;
notifyListeners();
}
} finally {
_laeuft = false;
_aktuell = null;
notifyListeners();
}
return neu;
}
/// Nimmt einen einzelnen Server-Titel offline.
///
/// Bewusst über [lade]: das bringt Doppel-Lauf-Schutz, Verbindungsprüfung
/// und Fortschritts-Buchführung mit. Gibt zurück, ob der Titel neu
/// dazugekommen ist.
///
/// **Bekannte Einschränkung:** Liegt der Titel schon im Abspiel-Zwischen-
/// speicher (weil er gerade gehört wurde), wird er trotzdem neu geladen —
/// bis die Verdrängung greift, belegt er doppelten Platz. Der Gegenschutz
/// kostete einen eigenen Cache-Zugang in diesem Dienst; für drei Nutzer ist
/// das der schlechtere Tausch.
Future<bool> ladeEinzelnenTitel(SubsonicSong song) async =>
await lade([song]) > 0;
Future<_Ergebnis> _ladeEinen(SubsonicSong song) async {
try {
// Datei und Buchführung entstehen nacheinander. Bricht die App
// dazwischen ab, liegt eine Datei ohne Zeile herum — sie tauchte dann
// in keiner Liste auf und ließe sich nie wieder loswerden. Deshalb hier
// beides prüfen und die fehlende Zeile nachtragen, statt nur „ist schon
// da" zu melden.
final vorhanden = await speicher.treffer(song.id);
if (vorhanden != null) {
await _buchen(song, await vorhanden.length());
return _Ergebnis.schonDa;
}
final ziel = await speicher.ziel(song.id);
final datei =
await navidrome.ladeDateiHerunter(navidrome.streamUrl(song.id), ziel);
if (datei == null) {
_fehler = 'Nicht ladbar: ${song.titel}';
return _Ergebnis.fehler;
}
await _buchen(song, await datei.length());
return _Ergebnis.neu;
} catch (e) {
_fehler = 'Fehler bei ${song.titel}: $e';
debugPrint('Download fehlgeschlagen: $e');
return _Ergebnis.fehler;
}
}
Future<void> _buchen(SubsonicSong song, int bytes) =>
db.merkeDownload(DownloadsCompanion.insert(
navidromeId: song.id,
titel: song.titel,
kuenstler: Value(song.kuenstler.isEmpty ? null : song.kuenstler),
album: Value(song.album.isEmpty ? null : song.album),
groesseBytes: bytes,
geladenAmMs: DateTime.now().millisecondsSinceEpoch,
));
/// Nimmt einen heruntergeladenen Titel wieder vom Gerät.
/// Nimmt einen heruntergeladenen Titel wieder vom Gerät.
///
/// Die Zeile fällt nur, wenn die Datei wirklich weg ist — sonst bliebe eine
/// Datei ohne Buchführung liegen, die in keiner Liste mehr auftaucht.
Future<bool> entferne(String navidromeId) async {
await speicher.loesche(navidromeId);
if (await speicher.treffer(navidromeId) != null) {
_fehler = 'Datei ließ sich nicht entfernen';
notifyListeners();
return false;
}
await db.vergissDownload(navidromeId);
notifyListeners();
return true;
}
Future<void> entferneAlle() async {
await speicher.loescheAlle();
await db.vergissAlleDownloads();
notifyListeners();
}
}
+68
View File
@@ -0,0 +1,68 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
/// Ablage für bewusst heruntergeladene Titel.
///
/// Bewusst **nicht** im Cache-Ordner: den darf Android bei Speichernot ohne
/// Vorwarnung leeren — das ist sein Zweck. Einem Album, das jemand für die
/// Zugfahrt mitgenommen hat, darf genau das nicht passieren. Deshalb liegt
/// es neben der Datenbank im Support-Ordner, den das System in Ruhe lässt.
///
/// Die Trennung ist auch die Grundlage der Speicher-Rechnung: die Grenze für
/// den Zwischenspeicher gilt nur dort, Downloads sind unbegrenzt.
class DownloadSpeicher {
static const ordnerName = 'melo_downloads';
Directory? _ordner;
Future<Directory> _ordnerHolen() async {
final vorhanden = _ordner;
if (vorhanden != null) return vorhanden;
final basis = await getApplicationSupportDirectory();
final ordner = Directory(p.join(basis.path, ordnerName));
if (!await ordner.exists()) await ordner.create(recursive: true);
_ordner = ordner;
return ordner;
}
/// Dateiname eines Titels. Wie im Zwischenspeicher über die Server-ID und
/// nicht über die Stream-Adresse: die trägt bei jedem App-Start ein neues
/// Token und wäre als Schlüssel wertlos.
static String dateiName(String navidromeId) =>
'${md5.convert('navidrome:$navidromeId'.codeUnits)}.mp3';
/// Wohin der Titel geschrieben wird — die Datei muss es noch nicht geben.
Future<File> ziel(String navidromeId) async =>
File(p.join((await _ordnerHolen()).path, dateiName(navidromeId)));
/// Die fertige Datei, oder `null`, wenn der Titel nicht heruntergeladen ist.
Future<File?> treffer(String navidromeId) async {
final datei = await ziel(navidromeId);
return await datei.exists() ? datei : null;
}
Future<void> loesche(String navidromeId) async {
try {
final datei = await ziel(navidromeId);
if (await datei.exists()) await datei.delete();
} catch (e) {
debugPrint('Download nicht löschbar: $e');
}
}
Future<void> loescheAlle() async {
try {
final ordner = await _ordnerHolen();
if (await ordner.exists()) await ordner.delete(recursive: true);
_ordner = null;
await _ordnerHolen();
} catch (e) {
debugPrint('Downloads nicht löschbar: $e');
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/services.dart';
/// Erste Web-Adresse in [text], oder `null`.
///
/// Wer ein Video an Melo teilt, schickt selten nur die Adresse — die
/// YouTube-App hängt Titel und „über @YouTube" drumherum.
///
/// Bewusst **nicht** auf youtube.com eingeschränkt: das Herauslösen der
/// Adresse aus dem geteilten Text ist unabhängig von ihrer Herkunft. Ob eine
/// nicht-YouTube-Adresse tatsächlich heruntergeladen werden kann, entscheidet
/// [YtDownloadService.herunterladen] — der lehnt sie heute client-seitig ab
/// (siehe dort), unabhängig davon, was der Server könnte.
String? adresseAus(String? text) {
if (text == null || text.isEmpty) return null;
final treffer = _adresse.firstMatch(text);
if (treffer == null) return null;
// Satzzeichen am Ende gehören zum Satz, nicht zur Adresse.
return treffer.group(0)!.replaceAll(RegExp(r'[.,;:!?)\]]+$'), '');
}
final _adresse = RegExp(r'https?://\S+');
/// Nimmt entgegen, was andere Apps über „Teilen" an Melo schicken.
///
/// Eigene Brücke statt eines Zusatzpakets — das Projekt hat für MediaStore
/// und Klangeffekte bereits eigene (siehe `MediaStoreBridge`).
class GeteilteAdresse {
const GeteilteAdresse({this.channel = const MethodChannel(kanalName)});
static const kanalName = 'de.baka.melo/geteilt';
final MethodChannel channel;
/// Die Adresse, mit der die App gerade geöffnet wurde, oder `null`.
///
/// Wird beim Abholen auf der Android-Seite gelöscht: sonst spränge die App
/// bei jedem Wiedereintritt erneut in den Downloader.
Future<String?> abholen() async {
try {
final text = await channel.invokeMethod<String>('hole');
return adresseAus(text);
} on PlatformException {
return null;
} on MissingPluginException {
// Andere Plattformen kennen die Brücke nicht — dort gibt es kein
// Teilen an Melo, und das ist kein Fehler.
return null;
}
}
}
+151 -5
View File
@@ -42,6 +42,14 @@ class CloudSong {
);
}
/// Eine Playlist, wie sie der Server kennt. Mehr als Name und ID braucht die
/// einseitige Sicherung nicht.
class CloudPlaylist {
const CloudPlaylist({required this.id, required this.name});
final String id;
final String name;
}
/// Ein Wiedergabe-Ereignis, das zum Server gemeldet wird.
class CloudVerlauf {
const CloudVerlauf({
@@ -114,10 +122,22 @@ class MeloCloudService {
}
/// Liest die Favoriten-IDs aus einer Server-Antwort.
///
/// Ein fehlender `favorites`-Schlüssel ist ein **Fehler, keine leere
/// Menge**: der Router verdrahtet für `GET /favorites` hart HTTP 200, und
/// mehrere Handler desselben Servers melden Fehler im 200er-Körper. Eine
/// fälschlich leere Antwort wäre sonst von einer echten nicht zu
/// unterscheiden — genau wie bei [parseListe] und [parseUpload] wird
/// deshalb geworfen.
@visibleForTesting
static List<String> parseFavoriten(String body) {
final daten = jsonDecode(body) as Map<String, dynamic>;
final liste = daten['favorites'] as List? ?? const [];
final fehler = daten['error'] as String?;
if (fehler != null) throw CloudException(fehler);
final liste = daten['favorites'];
if (liste is! List) {
throw CloudException('Antwort ohne Favoritenliste');
}
return [
for (final j in liste) (j as Map<String, dynamic>)['id'] as String,
];
@@ -264,19 +284,145 @@ class MeloCloudService {
return parseFavoriten(antwort.body);
}
/// Ersetzt die Favoriten am Server durch [cloudIds].
Future<void> setzeFavoriten(List<String> cloudIds) async {
/// Setzt einen einzelnen Favoriten am Server — additiv oder entfernend,
/// aber immer **deterministisch**.
///
/// Bewusst kein Umschalten: hätte der Server einen abweichenden Stand,
/// kehrte ein Toggle den Wunsch des Nutzers um.
Future<void> setzeFavorit(String cloudId, bool gesetzt) async {
_pruefeAnmeldung();
final antwort = await _client
.post(
Uri.parse('$basisUrl/favorites'),
Uri.parse('$basisUrl/favorites/toggle'),
headers: {..._kopf, 'Content-Type': 'application/json'},
body: jsonEncode({'song_ids': cloudIds}),
body: jsonEncode({'song_id': cloudId, 'set': gesetzt}),
)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
}
/// Der Körper einer Playlisten-Antwort, oder `CloudException`.
///
/// Der Server meldet Fehler im 200er-Körper unter `error`. Ein bloßes
/// `{"status":"not_found"}` **ohne** `error` (so antworten
/// `handle_playlist_remove_song` und `handle_playlist_update_positions`)
/// geht hier bewusst durch: die Sicherung ist einseitig und
/// fire-and-forget, sie verwirft jeden Fehler ohnehin.
Map<String, dynamic> _json(http.Response antwort) {
final daten = jsonDecode(antwort.body) as Map<String, dynamic>;
final fehler = daten['error'] as String?;
if (fehler != null) throw CloudException(fehler);
return daten;
}
/// Legt eine Playlist am Server an und gibt deren ID zurück.
///
/// Die Identität stammt **immer** von hier: `user_playlists.id` ist
/// AUTOINCREMENT und damit stabil. Eine Zuordnung über den Namen gibt es
/// nicht — sie zerbräche beim ersten Umbenennen.
Future<String> legePlaylistAn(String name) async {
_pruefeAnmeldung();
final antwort = await _client
.post(
Uri.parse('$basisUrl/playlists'),
headers: {..._kopf, 'Content-Type': 'application/json'},
body: jsonEncode({'name': name}),
)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
// handle_playlist_create antwortet {"status":"ok","playlist":{"id":…}} —
// die ID liegt eine Ebene tiefer, nicht auf oberster Ebene.
final playlist = _json(antwort)['playlist'];
if (playlist is! Map<String, dynamic>) {
throw CloudException('Antwort ohne Playlist');
}
return '${playlist['id']}';
}
Future<void> fuegePlaylistSongsHinzu(
String playlistCloudId, List<String> songCloudIds) async {
if (songCloudIds.isEmpty) return;
_pruefeAnmeldung();
final antwort = await _client
.post(
Uri.parse('$basisUrl/playlists/$playlistCloudId/songs'),
headers: {..._kopf, 'Content-Type': 'application/json'},
body: jsonEncode({'song_ids': songCloudIds}),
)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
_json(antwort);
}
Future<void> entfernePlaylistSong(
String playlistCloudId, String songCloudId) async {
_pruefeAnmeldung();
final antwort = await _client
.delete(
Uri.parse('$basisUrl/playlists/$playlistCloudId/songs/$songCloudId'),
headers: _kopf,
)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
_json(antwort);
}
/// Schreibt die Reihenfolge einer Playlist am Server fest.
///
/// Der Körper heißt `positions` und trägt Paare aus `id` und `position`:
/// Der Router liest `d.get('positions',[])`, der Handler greift je Eintrag
/// auf beide Schlüssel zu. Eine blanke ID-Liste unter `song_ids` käme als
/// leere Liste an — der Server antwortete stumm `{"status":"ok"}` und
/// änderte nichts.
Future<void> setzePlaylistReihenfolge(
String playlistCloudId, List<String> songCloudIds) async {
_pruefeAnmeldung();
final antwort = await _client
.put(
Uri.parse('$basisUrl/playlists/$playlistCloudId/positions'),
headers: {..._kopf, 'Content-Type': 'application/json'},
body: jsonEncode({
'positions': [
for (var i = 0; i < songCloudIds.length; i++)
{'id': songCloudIds[i], 'position': i},
],
}),
)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
_json(antwort);
}
Future<List<CloudPlaylist>> playlisten() async {
_pruefeAnmeldung();
final antwort = await _client
.get(Uri.parse('$basisUrl/playlists'), headers: _kopf)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
final liste = _json(antwort)['playlists'];
if (liste is! List) throw CloudException('Antwort ohne Playlisten');
return [
for (final j in liste)
CloudPlaylist(
id: '${(j as Map<String, dynamic>)['id']}',
name: j['name'] as String? ?? 'Ohne Namen',
),
];
}
Future<List<String>> playlistSongs(String playlistCloudId) async {
_pruefeAnmeldung();
final antwort = await _client
.get(Uri.parse('$basisUrl/playlists/$playlistCloudId'), headers: _kopf)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
final liste = _json(antwort)['songs'];
if (liste is! List) throw CloudException('Antwort ohne Titel');
return [
for (final j in liste) '${(j as Map<String, dynamic>)['id']}',
];
}
/// Meldet Wiedergaben. Der Server nimmt höchstens 100 je Aufruf an und
/// verwirft Doppelmeldungen desselben Titels innerhalb einer Stunde.
Future<void> meldeVerlauf(List<CloudVerlauf> eintraege) async {
+58 -21
View File
@@ -42,12 +42,14 @@ class SubsonicAlbum {
final String name;
final String? coverId;
final int songCount;
final String? kuenstler;
const SubsonicAlbum({
required this.id,
required this.name,
this.coverId,
this.songCount = 0,
this.kuenstler,
});
factory SubsonicAlbum.fromJson(Map<String, dynamic> j) => SubsonicAlbum(
@@ -55,6 +57,7 @@ class SubsonicAlbum {
name: j['name'] as String? ?? j['title'] as String? ?? 'Unbekannt',
coverId: j['coverArt'] as String?,
songCount: (j['songCount'] as int?) ?? 0,
kuenstler: j['artist'] as String?,
);
}
@@ -137,6 +140,12 @@ class NavidromeService {
bool get istVerbunden => _serverUrl.isNotEmpty && _user.isNotEmpty;
/// Für die automatische Baka-Anmeldung verbundener Server-User (siehe
/// [BakaAuth.autoAnmelden]) — Navidrome- und Baka-Konto teilen sich die
/// Zugangsdaten.
String get benutzer => _user;
String get passwort => _password;
/// Dekodiert eine Subsonic-JSON-Antwort und wirft [NavidromeException],
/// wenn der Server `status:"failed"` meldet (z. B. falsches Passwort) —
/// solche Antworten kommen mit HTTP 200 und dürfen nicht als „leer" gelten.
@@ -229,12 +238,15 @@ class NavidromeService {
}
}
Future<List<SubsonicAlbum>> getAlben({int anzahl = 50}) async {
/// [ab] überspringt die ersten Alben — damit lässt sich über die erste
/// Seite hinaus nachladen, statt bei 50 stehenzubleiben.
Future<List<SubsonicAlbum>> getAlben({int anzahl = 50, int ab = 0}) async {
try {
// getAlbumList2 = ID3-basiert (wie getArtists) — der Folder-Endpoint
// getAlbumList liefert bei tag-organisierten Navidrome-Libraries leer.
final r = await http
.get(_uri('getAlbumList2.view', {'type': 'newest', 'size': '$anzahl'}))
.get(_uri('getAlbumList2.view',
{'type': 'newest', 'size': '$anzahl', 'offset': '$ab'}))
.timeout(const Duration(seconds: 15));
if (r.statusCode != 200) throw NavidromeException('HTTP ${r.statusCode}');
return parseAlben(parseSubsonic(r.body));
@@ -244,16 +256,23 @@ class NavidromeService {
}
}
/// Die Titel eines Albums.
///
/// Wirft bei Fehlern weiter, statt eine leere Liste zu liefern: „keine
/// Titel" und „Server nicht erreichbar" sehen sonst gleich aus, und der
/// Nutzer bekäme keinen Wiederholen-Knopf zu sehen.
Future<List<SubsonicSong>> getSongs(String albumId) async {
try {
final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15));
if (r.statusCode != 200) return [];
final data = jsonDecode(r.body);
final songs = data['subsonic-response']?['album']?['song'] as List? ?? [];
final r = await http
.get(_uri('getAlbum.view', {'id': albumId}))
.timeout(const Duration(seconds: 15));
if (r.statusCode != 200) throw NavidromeException('HTTP ${r.statusCode}');
final data = parseSubsonic(r.body);
final songs = data['album']?['song'] as List? ?? [];
return songs.map((j) => SubsonicSong.fromJson(j)).toList();
} catch (e) {
await logger.error('Navidrome getSongs Fehler: $e', e, StackTrace.current);
return [];
rethrow;
}
}
@@ -318,23 +337,31 @@ class NavidromeService {
}
}
/// Alle Titel eines Künstlers, Album für Album.
///
/// `getArtist` liefert die Alben **ohne** ihre Titel — das steht so in der
/// Subsonic-API. Vorher las diese Methode `album[].song`, was es dort nie
/// gibt: die Liste war immer leer, und der Künstler wirkte titellos.
/// Deshalb je Album ein `getAlbum` hinterher.
Future<List<SubsonicSong>> getArtistSongs(String artistId) async {
try {
final r = await http.get(_uri('getArtist.view', {'id': artistId})).timeout(const Duration(seconds: 15));
if (r.statusCode != 200) return [];
final data = jsonDecode(r.body);
final albums = data['subsonic-response']?['artist']?['album'] as List? ?? [];
final r = await http
.get(_uri('getArtist.view', {'id': artistId}))
.timeout(const Duration(seconds: 15));
if (r.statusCode != 200) throw NavidromeException('HTTP ${r.statusCode}');
final data = parseSubsonic(r.body);
final alben = data['artist']?['album'] as List? ?? [];
final songs = <SubsonicSong>[];
for (final album in albums) {
final albumSongs = album['song'] as List? ?? [];
for (final song in albumSongs) {
songs.add(SubsonicSong.fromJson(song));
}
for (final album in alben) {
final id = (album as Map)['id'] as String?;
if (id == null) continue;
songs.addAll(await getSongs(id));
}
return songs;
} catch (e) {
await logger.error('Navidrome getArtistSongs Fehler: $e', e, StackTrace.current);
return [];
await logger.error(
'Navidrome getArtistSongs Fehler: $e', e, StackTrace.current);
rethrow;
}
}
@@ -379,7 +406,18 @@ class NavidromeService {
Future<File?> ladeInCache(String songId, Uri streamUri, CacheManager cache) async {
final ziel = await cache.getCacheFile(cacheSchluessel(songId));
if (await ziel.exists()) return ziel;
return ladeDateiHerunter(streamUri, ziel);
}
/// Lädt [streamUri] vollständig nach [ziel] und gibt die fertige Datei
/// zurück, oder `null`, wenn dabei etwas schiefging.
///
/// Dieselbe Logik für den Zwischenspeicher wie für einen bewussten
/// Download — nur das Ziel unterscheidet sich. Geschrieben wird zuerst in
/// eine `.part`-Datei; erst der vollständige, als Audio bestätigte
/// Download wird umbenannt. Ein Abbruch hinterlässt damit keine halbe
/// Datei, die später als gültiger Treffer gälte.
Future<File?> ladeDateiHerunter(Uri streamUri, File ziel) async {
final teil = File('${ziel.path}.part');
final klient = http.Client();
try {
@@ -387,16 +425,15 @@ class NavidromeService {
.send(http.Request('GET', streamUri))
.timeout(const Duration(seconds: 30));
if (!istAudioAntwort(antwort.statusCode, antwort.headers['content-type'])) {
await logger.error('Cache abgebrochen: HTTP ${antwort.statusCode}, '
await logger.error('Download abgebrochen: HTTP ${antwort.statusCode}, '
'Typ ${antwort.headers['content-type'] ?? '-'}');
return null;
}
await antwort.stream.pipe(teil.openWrite());
await teil.rename(ziel.path);
debugPrint('Im Cache abgelegt: ${ziel.path}');
return ziel;
} catch (e) {
await logger.error('Cache-Download fehlgeschlagen: $e', e, StackTrace.current);
await logger.error('Download fehlgeschlagen: $e', e, StackTrace.current);
if (await teil.exists()) await teil.delete();
return null;
} finally {
+65
View File
@@ -0,0 +1,65 @@
/// Die reinen Entscheidungsfunktionen des Abgleichs — ohne Netz, ohne
/// Datenbank, ohne Plattform-Kanäle.
///
/// Sie liegen bewusst außerhalb von `SyncService`: was hier steht, lässt sich
/// mit einer Handvoll Mengen prüfen statt mit einem halben Server.
library;
/// Wie viele Favoriten je Lauf höchstens gepusht werden.
///
/// Der Rest kommt im nächsten Lauf dran. Die Pushes sind idempotent, ein
/// Teilausfall heilt sich dadurch von selbst.
const int maxFavoritenPushes = 200;
/// Die cloudIds, die zum Server gepusht werden müssen: `lokal \ server`.
///
/// [lokaleFavoriten] sind lokale Song-IDs, [cloudIdVon] bildet sie auf ihre
/// cloudId ab. Titel ohne cloudId kennt der Server nicht — sie tauchen in
/// keiner Richtung im Abgleich auf.
List<String> zuPushendeFavoriten({
required Set<String> lokaleFavoriten,
required Map<String, String> cloudIdVon,
required Set<String> amServer,
int deckel = maxFavoritenPushes,
}) {
final offen = <String>[];
for (final songId in lokaleFavoriten) {
final cloudId = cloudIdVon[songId];
if (cloudId == null) continue;
if (amServer.contains(cloudId)) continue;
offen.add(cloudId);
if (offen.length >= deckel) break;
}
return offen;
}
/// Die lokalen Song-IDs, die aus dem Server-Stand als Favorit dazukommen:
/// `server \ lokal`.
///
/// Eine cloudId ohne lokalen Titel (Download fehlgeschlagen, noch nicht
/// geladen) wird **übersprungen, nicht gelöscht**: ein Favorit ohne Song wäre
/// über den Join unsichtbar, würde aber weiter mitgeschleppt.
List<String> lokalZuSetzendeFavoriten({
required Set<String> amServer,
required Map<String, String> songIdVonCloudId,
required Set<String> lokaleFavoriten,
}) {
final offen = <String>[];
for (final cloudId in amServer) {
final songId = songIdVonCloudId[cloudId];
if (songId == null) continue;
if (lokaleFavoriten.contains(songId)) continue;
offen.add(songId);
}
return offen;
}
/// Ob der „Was ist neu"-Bericht fällig ist.
///
/// `null` heißt Neuinstallation, Abmeldung oder gelöschte App-Daten — dann ist
/// er **nicht** fällig, sonst begrüßt ein frisch eingerichtetes Gerät den
/// Nutzer mit „Willkommen zurück! 325 neue Songs". `sollAutoSync` entscheidet
/// bei `null` bewusst umgekehrt und ist hier **kein** Vorbild.
bool berichtFaellig(DateTime? letzterErfolg, DateTime jetzt) =>
letzterErfolg != null &&
jetzt.difference(letzterErfolg) >= const Duration(hours: 24);
+286 -19
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart' show Value;
@@ -9,6 +10,7 @@ import 'package:uuid/uuid.dart';
import '../library/database.dart';
import 'media_store.dart';
import 'melo_cloud_service.dart';
import 'sync_merge.dart';
/// Was beim Abgleich mit welchen Titeln zu tun ist.
///
@@ -120,6 +122,48 @@ const autoSyncAbstand = Duration(minutes: 15);
bool sollAutoSync(DateTime? letzterLauf, DateTime jetzt) =>
letzterLauf == null || jetzt.difference(letzterLauf) >= autoSyncAbstand;
/// Was ein gezielter Upload erledigt hat — Grundlage der Meldung an den
/// Nutzer. Ein einzelner Fehlschlag darf den Erfolg der übrigen nicht
/// verdecken, deshalb steht hier alles nebeneinander.
class UploadErgebnis {
const UploadErgebnis({
this.hochgeladen = 0,
this.schonDa = 0,
this.fehler = const [],
this.abgebrochen = false,
});
final int hochgeladen;
final int schonDa;
final List<String> fehler;
final bool abgebrochen;
String get meldung {
final teile = <String>[];
if (hochgeladen > 0) teile.add('$hochgeladen hochgeladen');
if (schonDa > 0) teile.add('$schonDa waren schon da');
if (fehler.isNotEmpty) teile.add('${fehler.length} fehlgeschlagen');
if (abgebrochen) teile.add('abgebrochen');
return teile.isEmpty ? 'Nichts zu tun' : teile.join(', ');
}
}
/// Was seit dem letzten erfolgreichen Abgleich passiert ist — der Inhalt des
/// „Willkommen zurück"-Dialogs.
class SyncBericht {
const SyncBericht({
required this.neueSongs,
required this.geloeschte,
required this.favoriten,
});
final int neueSongs;
final int geloeschte;
final int favoriten;
bool get istLeer => neueSongs == 0 && geloeschte == 0 && favoriten == 0;
}
/// Hält Gerät und Server auf demselben Stand: lädt neue Server-Titel herunter,
/// bringt eigene Dateien hoch, zieht Löschungen nach und meldet Favoriten und
/// Wiedergaben. Alle Geräte am selben Konto sehen dadurch dasselbe.
@@ -132,6 +176,7 @@ class SyncService extends ChangeNotifier {
}) : _musikOrdner = musikOrdner ?? _standardMusikOrdner;
static const _letzterLaufKey = 'cloud_sync_letzter_lauf';
static const _letzterErfolgKey = 'cloud_sync_letzter_erfolg';
static const _verlaufStandKey = 'cloud_sync_verlauf_stand';
static const _uuid = Uuid();
@@ -146,6 +191,9 @@ class SyncService extends ChangeNotifier {
String? _fehler;
String? _status;
DateTime? _letzterLauf;
DateTime? _letzterErfolg;
SyncBericht? _bericht;
bool _abbruchGewuenscht = false;
bool get laeuft => _laeuft;
int get erledigt => _erledigt;
@@ -154,10 +202,23 @@ class SyncService extends ChangeNotifier {
String? get status => _status;
DateTime? get letzterLauf => _letzterLauf;
/// Der fällige Bericht, oder `null`. Wird von der Oberfläche genau einmal
/// abgeholt und dann mit [berichtGesehen] quittiert.
SyncBericht? get bericht => _bericht;
void berichtGesehen() {
_bericht = null;
notifyListeners();
}
Future<void> laden() async {
final prefs = await SharedPreferences.getInstance();
final ms = prefs.getInt(_letzterLaufKey);
if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms);
final erfolg = prefs.getInt(_letzterErfolgKey);
if (erfolg != null) {
_letzterErfolg = DateTime.fromMillisecondsSinceEpoch(erfolg);
}
notifyListeners();
}
@@ -185,6 +246,14 @@ class SyncService extends ChangeNotifier {
_gesamt = 0;
_melde('Vergleiche mit dem Server …');
// Der Zeitstempel ist der Stand VOR dem Listen: was während des Laufs am
// Server passiert, muss beim nächsten Mal noch drankommen (v2-Lektion,
// Tombstone-Race).
final laufBeginn = DateTime.now();
var vollstaendig = true;
var neueSongs = 0;
var geloeschte = 0;
try {
final amServer = await cloud.liste();
final plan = planeSync(
@@ -204,18 +273,38 @@ class SyncService extends ChangeNotifier {
'$bestand Titeln würden am Server gelöscht. Das sieht nach einem '
'Fehler aus (z. B. Speicherkarte nicht eingehängt) — es wurde '
'nichts gelöscht.';
vollstaendig = false;
}
// Jede Phase für sich: fällt eine aus, laufen die übrigen weiter, und
// der Erfolgs-Zeitstempel bleibt stehen.
geloeschte = plan.lokalLoeschen.length;
await _ziehLoeschungenNach(plan.lokalLoeschen);
if (!bremse) await _meldeLoeschungen(plan.serverLoeschen);
await _ladeHerunter(plan.herunterladen);
await _ladeHoch(plan.hochladen);
await _gleicheFavoritenAb();
if (!bremse && !await _meldeLoeschungen(plan.serverLoeschen)) {
vollstaendig = false;
}
neueSongs = await _ladeHerunter(plan.herunterladen);
if (!await _ladeHoch(plan.hochladen)) vollstaendig = false;
if (!await _gleicheFavoritenAb()) vollstaendig = false;
await _meldeVerlauf();
_letzterLauf = DateTime.now();
_letzterLauf = laufBeginn;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_letzterLaufKey, _letzterLauf!.millisecondsSinceEpoch);
await prefs.setInt(_letzterLaufKey, laufBeginn.millisecondsSinceEpoch);
if (vollstaendig) {
// „Erfolgreich" heißt: keine Phase hat einen Fehler geschluckt.
if (berichtFaellig(_letzterErfolg, laufBeginn)) {
_bericht = SyncBericht(
neueSongs: neueSongs,
geloeschte: geloeschte,
favoriten: 0,
);
}
_letzterErfolg = laufBeginn;
await prefs.setInt(
_letzterErfolgKey, laufBeginn.millisecondsSinceEpoch);
}
} on CloudException catch (e) {
_fehler = e.message;
} catch (e) {
@@ -228,6 +317,104 @@ class SyncService extends ChangeNotifier {
}
}
/// Stoppt einen laufenden Auswahl-Upload zwischen zwei Titeln.
///
/// Nach dem Muster von `DownloadService.abbrechen`: wer versehentlich 60
/// statt 6 Titel markiert hat, soll nicht die App killen müssen.
void abbrechen() {
if (_laeuft) _abbruchGewuenscht = true;
}
/// Lädt genau [songs] zum Server — die ausdrückliche Nutzeraktion aus dem
/// Auswahl-Modus.
///
/// Sequenziell, weil der Server jeden Upload komplett im RAM hält. Titel mit
/// cloudId werden übersprungen, Einzelfehler vermerkt und übergangen.
/// Der Sync-Zeitstempel wird bewusst **nicht** geschrieben: ein Upload ist
/// kein Abgleich, und sonst unterdrückte er 15 Minuten den Auto-Sync und
/// verschöbe die 24-h-Uhr des Berichts.
///
/// Den Offline-Modus-Schalter beachtet er nicht — er ist eine ausdrückliche
/// Nutzeraktion.
Future<UploadErgebnis> ladeAusgewaehlteHoch(List<Song> songs) async {
if (_laeuft) {
_fehler = 'Es läuft gerade ein Abgleich — bitte kurz warten';
notifyListeners();
return const UploadErgebnis();
}
if (!cloud.istAngemeldet) {
_fehler = 'Bitte zuerst beim Baka-Konto anmelden';
notifyListeners();
return const UploadErgebnis();
}
_laeuft = true;
_abbruchGewuenscht = false;
_fehler = null;
_erledigt = 0;
_gesamt = songs.length;
notifyListeners();
var hochgeladen = 0;
var schonDa = 0;
final fehler = <String>[];
var abgebrochen = false;
try {
for (final song in songs) {
if (_abbruchGewuenscht) {
abgebrochen = true;
break;
}
if (song.cloudId != null) {
schonDa++;
_erledigt++;
notifyListeners();
continue;
}
final datei = File(song.path);
if (!await datei.exists()) {
fehler.add('${song.title}: Datei nicht gefunden');
_erledigt++;
notifyListeners();
continue;
}
_melde('Sende „${song.title}" …');
try {
final cloudId = await cloud.hochladen(
datei,
dateiname:
'${_sichererDateiname(song.title)}${p.extension(song.path)}',
);
if (cloudId != null) {
await db.setCloudId(song.id, cloudId);
hochgeladen++;
} else {
fehler.add('${song.title}: keine Server-ID erhalten');
}
} on CloudException catch (e) {
fehler.add('${song.title}: ${e.message}');
} on TimeoutException {
fehler.add('${song.title}: Zeitüberschreitung');
}
_erledigt++;
notifyListeners();
}
} finally {
_laeuft = false;
_abbruchGewuenscht = false;
_status = null;
notifyListeners();
}
return UploadErgebnis(
hochgeladen: hochgeladen,
schonDa: schonDa,
fehler: fehler,
abgebrochen: abgebrochen,
);
}
Future<void> _ziehLoeschungenNach(List<Song> songs) async {
if (songs.isEmpty) return;
_melde('Entferne ${songs.length} am Server gelöschte Titel …');
@@ -236,7 +423,13 @@ class SyncService extends ChangeNotifier {
notifyListeners();
}
Future<void> _meldeLoeschungen(List<Song> songs) async {
/// Meldet die hier getombsteten Titel am Server.
///
/// Gibt `false` zurück, sobald eine Meldung geschluckt wurde: der Lauf geht
/// weiter, gilt aber nicht mehr als erfolgreich — sonst rückte der
/// 24-h-Zeitstempel des Berichts vor, obwohl eine Phase ausgefallen ist.
Future<bool> _meldeLoeschungen(List<Song> songs) async {
var vollstaendig = true;
for (final song in songs) {
_melde('Melde Löschung von „${song.title}“ …');
try {
@@ -244,14 +437,19 @@ class SyncService extends ChangeNotifier {
} on CloudException catch (e) {
// Eine abgelehnte Löschung darf den Lauf nicht beenden.
debugPrint('Löschung „${song.title}“ übersprungen: ${e.message}');
vollstaendig = false;
}
_erledigt++;
notifyListeners();
}
return vollstaendig;
}
Future<void> _ladeHerunter(List<CloudSong> songs) async {
if (songs.isEmpty) return;
/// Gibt zurück, wie viele Titel wirklich neu dazugekommen sind — die Zahl
/// im „Was ist neu"-Bericht.
Future<int> _ladeHerunter(List<CloudSong> songs) async {
if (songs.isEmpty) return 0;
var neu = 0;
final ordner = await _musikOrdner();
for (final cloudSong in songs) {
_melde('Lade „${cloudSong.titel}“ …');
@@ -291,12 +489,21 @@ class SyncService extends ChangeNotifier {
cloudId: Value(cloudSong.id),
),
]);
neu++;
_erledigt++;
notifyListeners();
}
return neu;
}
Future<void> _ladeHoch(List<Song> songs) async {
/// Lädt alle Titel ohne cloudId hoch.
///
/// Gibt `false` zurück, sobald ein Upload an einem Fehler oder einer
/// Zeitüberschreitung hängenblieb. Eine **fehlende Datei** zählt bewusst
/// nicht dazu: dort ist nichts schiefgegangen, der Titel ist weg und der
/// nächste Scan tombstoned ihn.
Future<bool> _ladeHoch(List<Song> songs) async {
var vollstaendig = true;
for (final song in songs) {
final datei = File(song.path);
if (!await datei.exists()) {
@@ -309,29 +516,89 @@ class SyncService extends ChangeNotifier {
datei,
dateiname: '${_sichererDateiname(song.title)}${p.extension(song.path)}',
);
if (cloudId != null) await db.setCloudId(song.id, cloudId);
if (cloudId != null) {
await db.setCloudId(song.id, cloudId);
} else {
// 200 ohne Server-ID: der Titel ist oben nicht angekommen.
vollstaendig = false;
}
} on CloudException catch (e) {
// Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden.
debugPrint('Upload „${song.title}“ übersprungen: ${e.message}');
vollstaendig = false;
} on TimeoutException {
// Der 120-s-Timeout (melo_cloud_service.dart:180) wirft
// TimeoutException, nicht CloudException — ohne diesen Zweig riss ein
// einziger hängender Upload den ganzen Lauf ab.
debugPrint('Upload „${song.title}“: Zeitüberschreitung');
vollstaendig = false;
}
_erledigt++;
notifyListeners();
}
return vollstaendig;
}
Future<void> _gleicheFavoritenAb() async {
/// Additiver Favoriten-Abgleich: gleicht in **beide** Richtungen an,
/// entfernt aber in **keiner**.
///
/// Damit ist der alte Datenverlust-Bug strukturell unmöglich: es gibt
/// keinen Codepfad mehr, der den Server-Stand ersetzen könnte. Der Preis
/// ist bekannt und bewusst: ein Ent-Favorisieren propagiert nicht
/// geräteübergreifend — hält ein zweites Gerät den Favoriten noch, bringt
/// dessen nächster Abgleich ihn zurück.
///
/// Gibt `true` zurück, wenn die Phase vollständig durchlief.
Future<bool> _gleicheFavoritenAb() async {
_melde('Gleiche Favoriten ab …');
final Set<String> amServer;
try {
amServer = (await cloud.favoriten()).toSet();
} catch (e) {
// Ohne Server-Stand ist nichts zu tun. Blindes Pushen wäre harmlos,
// aber nutzlos — die Phase wird übersprungen, der Lauf geht weiter.
debugPrint('Favoriten-Abgleich übersprungen: $e');
return false;
}
// Grabsteine bleiben außen vor: favoriteSongIds() liefert auch Favoriten
// getombsteter Titel, und die gehören nicht zurück auf den Server.
final lokal = await db.allSongs();
final cloudIdVon = {
for (final s in lokal)
if (s.cloudId != null) s.id: s.cloudId!,
if (s.cloudId != null && !s.deleted) s.id: s.cloudId!,
};
final favoritenIds = await db.favoriteSongIds();
final cloudFavoriten = [
for (final id in favoritenIds)
if (cloudIdVon[id] != null) cloudIdVon[id]!,
];
await cloud.setzeFavoriten(cloudFavoriten);
final songIdVonCloudId = {
for (final s in lokal)
if (s.cloudId != null && !s.deleted) s.cloudId!: s.id,
};
final favoriten = (await db.favoriteSongIds()).toSet();
var vollstaendig = true;
for (final cloudId in zuPushendeFavoriten(
lokaleFavoriten: favoriten,
cloudIdVon: cloudIdVon,
amServer: amServer,
)) {
try {
await cloud.setzeFavorit(cloudId, true);
} catch (e) {
debugPrint('Favorit $cloudId nicht gemeldet: $e');
vollstaendig = false;
}
}
for (final songId in lokalZuSetzendeFavoriten(
amServer: amServer,
songIdVonCloudId: songIdVonCloudId,
lokaleFavoriten: favoriten,
)) {
await db.setFavorite(songId, true);
}
return vollstaendig;
}
Future<void> _meldeVerlauf() async {
+67 -10
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -10,6 +11,12 @@ import 'baka_auth.dart';
String sichererDateiname(String titel) =>
titel.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
/// Signalisiert, dass [YtDownloadService.abbrechen] während einer laufenden
/// Anfrage aufgerufen wurde.
class _Abgebrochen implements Exception {
const _Abgebrochen();
}
/// Ergebnis eines geglückten Downloads.
class YtErgebnis {
const YtErgebnis({
@@ -40,6 +47,11 @@ class YtDownloadService extends ChangeNotifier {
String? _status;
String? _fehler;
/// Ist gesetzt, während [herunterladen] läuft. [abbrechen] löst es aus —
/// die Netzwerk-Anfrage selbst läuft im Hintergrund weiter, bis der Socket
/// sie beendet, aber die App wartet nicht länger darauf.
Completer<void>? _abbruch;
bool get laeuft => _laeuft;
/// Was gerade passiert — für die Anzeige während des Downloads.
@@ -58,6 +70,20 @@ class YtDownloadService extends ChangeNotifier {
notifyListeners();
}
/// Bricht den laufenden Download ab, falls einer läuft.
void abbrechen() {
_abbruch?.complete();
}
/// Wartet auf [aufgabe], bricht aber sofort mit [_Abgebrochen] ab, sobald
/// [abbrechen] aufgerufen wird — ohne auf das Ende von [aufgabe] zu warten.
Future<T> _abbrechbar<T>(Future<T> aufgabe, Completer<void> abbruch) {
return Future.any<T>([
aufgabe,
abbruch.future.then((_) => throw const _Abgebrochen()),
]);
}
/// Lädt [url] herunter und legt die MP3 in [zielOrdner] ab.
/// Mit [cookies] nutzt der Proxy seine YouTube-Anmeldung — nötig für
/// altersbeschränkte Videos.
@@ -66,6 +92,7 @@ class YtDownloadService extends ChangeNotifier {
{required String zielOrdner, bool cookies = true}) async {
_fehler = null;
_laeuft = true;
final abbruch = _abbruch = Completer<void>();
notifyListeners();
if (!auth.istAngemeldet) {
@@ -81,13 +108,28 @@ class YtDownloadService extends ChangeNotifier {
_melde('Proxy wird gefragt …');
final http.Response antwort;
try {
antwort = await _client
.post(
Uri.parse('$proxyUrl/api/yt-dl'),
headers: {...auth.authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'url': url, 'cookies': cookies}),
)
.timeout(const Duration(seconds: 180));
antwort = await _abbrechbar(
_client
.post(
Uri.parse('$proxyUrl/api/yt-dl'),
headers: {
...auth.authHeader,
'Content-Type': 'application/json'
},
body: jsonEncode({'url': url, 'cookies': cookies}),
)
.timeout(const Duration(seconds: 180)),
abbruch,
);
} on _Abgebrochen {
_scheitere('Abgebrochen');
return null;
} on TimeoutException {
// Anders als bei einem Verbindungsfehler ist der Proxy hier vermutlich
// erreichbar, arbeitet nur noch — "nicht erreichbar" würde in die
// Irre führen.
_scheitere('Der Server braucht zu lange — bitte später erneut versuchen');
return null;
} catch (e) {
debugPrint('YT-Proxy nicht erreichbar: $e');
_scheitere('Proxy nicht erreichbar');
@@ -117,14 +159,29 @@ class YtDownloadService extends ChangeNotifier {
final datei = File('${ordner.path}/${sichererDateiname(titel)}.mp3');
try {
final mp3 = await _client
.get(Uri.parse('$proxyUrl$mp3Url'), headers: auth.authHeader)
.timeout(const Duration(seconds: 180));
final mp3 = await _abbrechbar(
_client
.get(Uri.parse('$proxyUrl$mp3Url'), headers: auth.authHeader)
.timeout(const Duration(seconds: 180)),
abbruch,
);
if (mp3.statusCode != 200) {
_scheitere('Herunterladen fehlgeschlagen (${mp3.statusCode})');
return null;
}
await datei.writeAsBytes(mp3.bodyBytes);
} on _Abgebrochen {
if (await datei.exists()) {
await datei.delete();
}
_scheitere('Abgebrochen');
return null;
} on TimeoutException {
if (await datei.exists()) {
await datei.delete();
}
_scheitere('Der Server braucht zu lange — bitte später erneut versuchen');
return null;
} catch (e) {
debugPrint('MP3-Abruf fehlgeschlagen: $e');
// Halbe Dateien nicht liegen lassen — sonst taucht Müll in der
+140
View File
@@ -0,0 +1,140 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'baka_auth.dart';
import 'yt_download_service.dart';
/// Ein Treffer der YouTube-Suche über den Baka-Proxy.
class YtSuchTreffer {
const YtSuchTreffer(
{required this.title, required this.url, required this.duration});
final String title;
final String url;
final Duration duration;
/// Vorschaubild über YouTubes öffentliches Thumbnail-CDN — braucht keinen
/// eigenen API-Zugriff, nur die Video-ID aus [url].
String? get thumbnailUrl {
final id = youtubeVideoId(url);
return id == null ? null : 'https://i.ytimg.com/vi/$id/mqdefault.jpg';
}
}
/// Video-ID aus einer YouTube-URL — deckt `watch?v=`, `youtu.be/` und
/// `/shorts/` ab. `null`, wenn sich keine ID erkennen lässt.
String? youtubeVideoId(String url) {
final uri = Uri.tryParse(url);
if (uri == null) return null;
final ausQuery = uri.queryParameters['v'];
if (ausQuery != null && ausQuery.isNotEmpty) return ausQuery;
final segmente = uri.pathSegments;
if (segmente.isEmpty) return null;
if (uri.host.contains('youtu.be')) return segmente.first;
final shortsIndex = segmente.indexOf('shorts');
if (shortsIndex != -1 && shortsIndex + 1 < segmente.length) {
return segmente[shortsIndex + 1];
}
return null;
}
/// 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})
: _client = client ?? http.Client();
static const proxyUrl = YtDownloadService.proxyUrl;
final BakaAuth auth;
final http.Client _client;
bool _laeuft = false;
String? _fehler;
List<YtSuchTreffer> _treffer = const [];
bool get laeuft => _laeuft;
String? get fehler => _fehler;
List<YtSuchTreffer> get treffer => _treffer;
Future<void> suchen(String query) async {
final q = query.trim();
if (q.isEmpty) return;
if (!auth.istAngemeldet) {
_treffer = const [];
_fehler = 'Bitte zuerst beim Baka-Konto anmelden';
notifyListeners();
return;
}
_laeuft = true;
_fehler = null;
notifyListeners();
final uri = Uri.parse('$proxyUrl/api/search')
.replace(queryParameters: {'q': q});
final http.Response antwort;
try {
antwort = await _client
.get(uri, headers: auth.authHeader)
.timeout(const Duration(seconds: 20));
} on TimeoutException {
_scheitere('Die Suche braucht zu lange — bitte später erneut versuchen');
return;
} catch (e) {
debugPrint('YT-Suche nicht erreichbar: $e');
_scheitere('Proxy nicht erreichbar');
return;
}
if (antwort.statusCode == 401) {
_scheitere('Anmeldung abgelaufen — bitte neu anmelden');
return;
}
if (antwort.statusCode != 200) {
_scheitere(_fehlerText(antwort));
return;
}
try {
final roh = jsonDecode(antwort.body) as List;
_treffer = [
for (final eintrag in roh.cast<Map<String, dynamic>>())
YtSuchTreffer(
title: eintrag['title'] as String? ?? '',
url: eintrag['url'] as String? ?? '',
duration:
Duration(seconds: (eintrag['duration'] as num?)?.toInt() ?? 0),
),
];
} catch (e) {
_scheitere('Antwort des Servers nicht lesbar');
return;
}
_laeuft = false;
notifyListeners();
}
void _scheitere(String text) {
_fehler = text;
_treffer = const [];
_laeuft = false;
notifyListeners();
}
String _fehlerText(http.Response antwort) {
try {
final j = jsonDecode(antwort.body) as Map<String, dynamic>;
final text = j['error'] as String?;
if (text != null && text.isNotEmpty) return text;
} catch (_) {
// Kein JSON — dann eben der Statuscode.
}
return 'Proxy-Fehler (${antwort.statusCode})';
}
}
+61
View File
@@ -7,10 +7,34 @@ class AppSettings extends ChangeNotifier {
static const _keyGroupCovers = 'gleiche_kategorie_gleiches_cover';
static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer';
static const _keyNormalizeVolume = 'gleiche_lautstaerke';
static const _keyCacheGrenze = 'cache_grenze_mb';
static const _keyMeineMusikSektionen = 'meine_musik_sektionen';
/// Schnellzugriffe oben in „Meine Musik" — Schlüssel für die
/// [meineMusikSektionen]-Auswahl in den Einstellungen.
static const sektionFavoriten = 'favoriten';
static const sektionWiedergabelisten = 'wiedergabelisten';
static const sektionZuletzt = 'zuletzt';
/// Alle wählbaren Sektionen, in der Reihenfolge, in der sie oben erscheinen.
static const alleMeineMusikSektionen = [
sektionFavoriten,
sektionWiedergabelisten,
sektionZuletzt,
];
/// Voreinstellung für den Zwischenspeicher: 2 GB. Groß genug für einige
/// hundert Titel, klein genug, um ein Telefon nicht zuzumüllen.
static const cacheGrenzeStandardMb = 2048;
/// Zur Auswahl stehende Grenzen in MB. 0 heißt: gar nicht zwischenspeichern.
static const cacheGrenzenMb = [0, 512, 1024, 2048, 4096, 8192];
bool _groupCoversByCategory = true;
bool _autoPlayOnHeadphones = false;
bool _normalizeVolume = true;
int _cacheGrenzeMb = cacheGrenzeStandardMb;
Set<String> _meineMusikSektionen = Set.of(alleMeineMusikSektionen);
/// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der
/// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild.
@@ -23,14 +47,37 @@ class AppSettings extends ChangeNotifier {
/// auf das Niveau der leiseren abgesenkt.
bool get normalizeVolume => _normalizeVolume;
/// Obergrenze des Zwischenspeichers in MB. Gilt nur für das, was beim
/// Abspielen nebenbei anfällt — bewusst heruntergeladene Titel liegen in
/// einem eigenen Ordner und werden nie verdrängt.
int get cacheGrenzeMb => _cacheGrenzeMb;
int get cacheGrenzeBytes => _cacheGrenzeMb * 1024 * 1024;
/// Welche Schnellzugriffe oben in „Meine Musik" angezeigt werden.
Set<String> get meineMusikSektionen => _meineMusikSektionen;
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
_groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true;
_autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false;
_normalizeVolume = prefs.getBool(_keyNormalizeVolume) ?? true;
_cacheGrenzeMb = prefs.getInt(_keyCacheGrenze) ?? cacheGrenzeStandardMb;
final gespeicherteSektionen = prefs.getStringList(_keyMeineMusikSektionen);
_meineMusikSektionen = gespeicherteSektionen == null
? Set.of(alleMeineMusikSektionen)
: Set.of(gespeicherteSektionen);
notifyListeners();
}
Future<void> setCacheGrenzeMb(int mb) async {
if (_cacheGrenzeMb == mb) return;
_cacheGrenzeMb = mb;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyCacheGrenze, mb);
}
Future<void> setGroupCoversByCategory(bool value) async {
if (_groupCoversByCategory == value) return;
_groupCoversByCategory = value;
@@ -54,4 +101,18 @@ class AppSettings extends ChangeNotifier {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyAutoPlayHeadphones, value);
}
/// Blendet eine Schnellzugriff-Sektion oben in „Meine Musik" ein oder aus.
Future<void> setMeineMusikSektionAktiv(String sektion, bool aktiv) async {
final aktuell = aktiv == _meineMusikSektionen.contains(sektion);
if (aktuell) return;
_meineMusikSektionen = Set.of(_meineMusikSektionen);
aktiv
? _meineMusikSektionen.add(sektion)
: _meineMusikSektionen.remove(sektion);
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_keyMeineMusikSektionen, _meineMusikSektionen.toList());
}
}
+175 -35
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import '../downloads/downloads_liste_screen.dart';
import '../library/database.dart';
import '../library/library_service.dart';
import '../library/permissions.dart';
@@ -31,11 +33,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
late final CacheManager _cache = CacheManager();
int _cacheSize = 0;
/// "1.2.3 (45)" — leer, solange noch nicht geladen. Praktisch beim Melden
/// von Bugs oder um zu prüfen, ob ein Update angekommen ist.
String _version = '';
@override
void initState() {
super.initState();
_navidrome.ladeGespeicherteZugangsdaten();
_cache.init().then((_) => _updateCacheSize());
PackageInfo.fromPlatform().then((info) {
if (mounted) {
setState(() => _version = '${info.version} (${info.buildNumber})');
}
});
}
Future<void> _updateCacheSize() async {
@@ -62,7 +73,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
appBar: AppBar(title: const Text('Einstellungen')),
body: ListView(
children: [
const _SectionLabel('Bibliothek'),
@@ -132,7 +143,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
secondary: const Icon(Icons.equalizer),
title: const Text('Gleiche Lautstärke'),
subtitle: const Text(
'Senkt laute Titel ab (nur bei Dateien mit ReplayGain-Tag)'),
'Senkt laute Titel automatisch ab (wirkt nur bei Titeln '
'mit Lautstärke-Metadaten)'),
value: settings.normalizeVolume,
onChanged: settings.setNormalizeVolume,
),
@@ -148,6 +160,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const Divider(height: 1),
const _SectionLabel('Meine Musik'),
Consumer<AppSettings>(
builder: (context, settings, _) => Column(
children: [
for (final sektion in AppSettings.alleMeineMusikSektionen)
CheckboxListTile(
secondary: Icon(_sektionIcon(sektion)),
title: Text(_sektionLabel(sektion)),
value: settings.meineMusikSektionen.contains(sektion),
onChanged: (aktiv) => settings.setMeineMusikSektionAktiv(
sektion, aktiv ?? false),
),
],
),
),
const Divider(height: 1),
const _SectionLabel('Darstellung'),
Consumer<AppSettings>(
builder: (context, settings, _) => SwitchListTile(
@@ -164,9 +192,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
ListTile(
leading: const Icon(Icons.cloud_circle),
title: const Text('Navidrome'),
subtitle: _navidrome.istVerbunden
? const Text('Verbunden')
: const Text('Nicht verbunden'),
// "Navidrome" allein ist nur der Name der Server-Software — ohne
// den Zusatz muss man raten, wofür der Eintrag da ist.
subtitle: Text(_navidrome.istVerbunden
? 'Eigener Musik-Server · Verbunden'
: 'Eigener Musik-Server (optional) · Nicht verbunden'),
trailing: _navidrome.istVerbunden
? IconButton(
icon: const Icon(Icons.logout),
@@ -187,8 +217,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
ListTile(
leading: const Icon(Icons.wifi_off),
title: const Text('📴 Offline-Modus'),
subtitle: const Text('Nur gecachte Lieder abspielen'),
title: const Text('Offline-Modus'),
subtitle: const Text(
'Titel vom Server nur abspielen, wenn sie schon hier liegen'),
trailing: Consumer<OfflineMode>(
builder: (context, offlineMode, _) {
return Switch(
@@ -198,18 +229,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
),
ListTile(
leading: const Icon(Icons.storage),
title: const Text('Cache-Speicher'),
subtitle: Text(_formatBytes(_cacheSize)),
trailing: _cacheSize > 0
? IconButton(
tooltip: 'Cache löschen',
icon: const Icon(Icons.delete),
onPressed: () => _clearCache(),
)
: null,
),
ListTile(
leading: const Icon(Icons.favorite),
title: const Text('Favoriten-Sync'),
@@ -230,25 +249,91 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () => _syncServerPlaylists(),
),
),
const _SectionLabel('Speicher'),
StreamBuilder<List<Download>>(
stream: db.watchDownloads(),
builder: (context, snapshot) {
final eintraege = snapshot.data ?? const <Download>[];
final bytes =
eintraege.fold<int>(0, (summe, e) => summe + e.groesseBytes);
return ListTile(
leading: const Icon(Icons.download_for_offline_outlined),
title: const Text('Heruntergeladen'),
subtitle: Text(eintraege.isEmpty
? 'Noch nichts heruntergeladen'
: '${eintraege.length} Titel · '
'${DownloadsListeScreen.formatiereBytes(bytes)}'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const DownloadsListeScreen(),
)),
);
},
),
ListTile(
leading: const Icon(Icons.storage),
title: const Text('Zwischenspeicher'),
// Zwei Zahlen, weil es zwei Töpfe sind: was hier steht, ist beim
// Abspielen nebenbei angefallen und darf verdrängt werden.
subtitle: Text('${_formatBytes(_cacheSize)} von '
'${context.watch<AppSettings>().cacheGrenzeMb} MB'),
trailing: _cacheSize > 0
? IconButton(
tooltip: 'Zwischenspeicher leeren',
icon: const Icon(Icons.delete),
onPressed: () => _clearCache(),
)
: null,
),
ListTile(
leading: const Icon(Icons.tune),
title: const Text('Obergrenze des Zwischenspeichers'),
subtitle: const Text(
'Gilt nicht für heruntergeladene Titel — die bleiben.'),
trailing: DropdownButton<int>(
value: context.watch<AppSettings>().cacheGrenzeMb,
items: [
for (final mb in AppSettings.cacheGrenzenMb)
DropdownMenuItem(
value: mb,
child: Text(mb == 0 ? 'aus' : '$mb MB'),
),
],
onChanged: (mb) {
if (mb != null) context.read<AppSettings>().setCacheGrenzeMb(mb);
},
),
),
const Divider(height: 1),
const _SectionLabel('Geräte-Abgleich'),
const _CloudSyncBereich(),
const Divider(height: 1),
const _SectionLabel('Berechtigungen'),
ListTile(
leading: const Icon(Icons.mic_none),
leading: const Icon(Icons.perm_media_outlined),
title: const Text('Musik-Berechtigung'),
trailing: TextButton(
onPressed: openMusicPermissionSettings,
child: const Text('Einstellungen öffnen'),
),
),
ListTile(
leading: const Icon(Icons.notifications_outlined),
title: const Text('Benachrichtigungs-Berechtigung'),
subtitle: const Text('Für die Steuerung im Sperrbildschirm'),
trailing: TextButton(
onPressed: openMusicPermissionSettings,
child: const Text('Einstellungen öffnen'),
),
),
const Divider(height: 1),
const _SectionLabel('Über Melo'),
const ListTile(
leading: Icon(Icons.info_outline),
title: Text('Melo'),
subtitle: Text('Deine Musik. Offline. Kein Abo.'),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('Melo'),
subtitle: Text(_version.isEmpty
? 'Deine Musik. Offline. Kein Abo.'
: 'Deine Musik. Offline. Kein Abo. · Version $_version'),
),
],
),
@@ -267,9 +352,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
backgroundColor: Colors.grey.shade900,
backgroundColor: MeloTheme.surface,
title: const Text('Navidrome-Anmeldung',
style: TextStyle(color: Colors.white, fontSize: 16)),
style: TextStyle(color: MeloTheme.text1, fontSize: 16)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -293,8 +378,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () => setDialogState(() => selectedUser = 'Baka'),
style: FilledButton.styleFrom(
backgroundColor: selectedUser == 'Baka'
? Colors.redAccent
: Colors.grey.shade700,
? MeloTheme.red
: MeloTheme.surfaceHigh,
),
child: const Text('Baka'),
),
@@ -305,8 +390,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () => setDialogState(() => selectedUser = 'Tinker'),
style: FilledButton.styleFrom(
backgroundColor: selectedUser == 'Tinker'
? Colors.redAccent
: Colors.grey.shade700,
? MeloTheme.red
: MeloTheme.surfaceHigh,
),
child: const Text('Tinker'),
),
@@ -317,8 +402,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () => setDialogState(() => selectedUser = 'Dustin'),
style: FilledButton.styleFrom(
backgroundColor: selectedUser == 'Dustin'
? Colors.redAccent
: Colors.grey.shade700,
? MeloTheme.red
: MeloTheme.surfaceHigh,
),
child: const Text('Dustin'),
),
@@ -357,7 +442,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.redAccent),
strokeWidth: 2, color: MeloTheme.red),
),
),
],
@@ -400,7 +485,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
},
child: const Text('Login',
style: TextStyle(color: Colors.redAccent)),
style: TextStyle(color: MeloTheme.red)),
),
],
),
@@ -412,11 +497,51 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Future<void> _trennNavidrome() async {
final ja = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Von Navidrome abmelden?'),
content: const Text(
'Die gespeicherten Zugangsdaten werden gelöscht. Für die '
'erneute Anmeldung brauchst du Server-Adresse, Benutzername '
'und Passwort wieder.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Abmelden'),
),
],
),
);
if (ja != true) return;
await _navidrome.loescheZugangsdaten();
if (mounted) setState(() {});
}
Future<void> _clearCache() async {
final ja = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Zwischenspeicher leeren?'),
content: Text('${_formatBytes(_cacheSize)} werden gelöscht. '
'Beim nächsten Abspielen lädt Melo diese Titel erneut.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Leeren'),
),
],
),
);
if (ja != true) return;
await _cache.clearCache();
if (mounted) {
setState(() => _cacheSize = 0);
@@ -477,6 +602,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
/// Icon und Beschriftung der Schnellzugriff-Sektionen aus [AppSettings].
IconData _sektionIcon(String sektion) => switch (sektion) {
AppSettings.sektionFavoriten => Icons.favorite,
AppSettings.sektionWiedergabelisten => Icons.queue_music,
AppSettings.sektionZuletzt => Icons.schedule,
_ => Icons.circle,
};
String _sektionLabel(String sektion) => switch (sektion) {
AppSettings.sektionFavoriten => 'Favoriten',
AppSettings.sektionWiedergabelisten => 'Wiedergabelisten',
AppSettings.sektionZuletzt => 'Zuletzt hinzugefügt',
_ => sektion,
};
/// Kleine graue Überschrift über einer Einstellungs-Gruppe.
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.label);
@@ -513,7 +653,7 @@ class _CloudSyncBereich extends StatelessWidget {
leading: Icon(Icons.cloud_off),
title: Text('Nicht angemeldet'),
subtitle: Text(
'Für den Abgleich im Tab „Online“ beim Baka-Konto anmelden'),
'Für den Abgleich im Download-Tab beim Baka-Konto anmelden'),
);
}
+107
View File
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'theme.dart';
/// Ab welcher Länge sich der Schnellzugriff lohnt.
///
/// Darunter scrollt man in zwei Wischern durch — die Leiste wäre dann nur
/// eine schmale Fläche, die Platz nimmt und Fehltreffer produziert.
const alphabetAb = 30;
/// Wie viele **verschiedene** Anfangsbuchstaben es mindestens braucht.
///
/// Ein Album mit 40 Titeln „Track 01…40" hat genau einen — eine Leiste mit
/// einem Buchstaben ist kein Schnellzugriff, sondern nur eine Fläche.
const alphabetMindestBuchstaben = 3;
/// Feste Breite der Leiste.
///
/// Bewusst fest und nicht mitwachsend: vorher skalierte jeder Buchstabe auf
/// die volle Höhe seines Platzes, die Leiste wurde dadurch **breiter**, je
/// weniger Buchstaben es gab — bei einem einzigen schob sie die Songliste
/// vollständig aus dem Bild.
const alphabetBreite = 24.0;
/// Schmale AZ#-Leiste am rechten Rand. Tippen und Ziehen springt zur
/// ersten Stelle des Buchstabens.
///
/// Zeigt **nur** die Buchstaben, unter denen wirklich etwas steht: eine volle
/// AZ-Leiste in einer Bibliothek mit vier Künstlern wäre zum größten Teil
/// tote Fläche.
class AlphabetLeiste extends StatefulWidget {
const AlphabetLeiste({
super.key,
required this.buchstaben,
required this.onGewaehlt,
});
final List<String> buchstaben;
final ValueChanged<String> onGewaehlt;
@override
State<AlphabetLeiste> createState() => _AlphabetLeisteState();
}
class _AlphabetLeisteState extends State<AlphabetLeiste> {
String? _beruehrt;
void _waehleBei(double y, double hoehe) {
if (widget.buchstaben.isEmpty) return;
final proBuchstabe = hoehe / widget.buchstaben.length;
final index =
(y / proBuchstabe).floor().clamp(0, widget.buchstaben.length - 1);
final buchstabe = widget.buchstaben[index];
if (buchstabe == _beruehrt) return;
setState(() => _beruehrt = buchstabe);
widget.onGewaehlt(buchstabe);
}
@override
Widget build(BuildContext context) {
if (widget.buchstaben.isEmpty) return const SizedBox.shrink();
return SizedBox(
width: alphabetBreite,
child: LayoutBuilder(
builder: (context, raum) => GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragStart: (d) =>
_waehleBei(d.localPosition.dy, raum.maxHeight),
onVerticalDragUpdate: (d) =>
_waehleBei(d.localPosition.dy, raum.maxHeight),
onVerticalDragEnd: (_) => setState(() => _beruehrt = null),
onVerticalDragCancel: () => setState(() => _beruehrt = null),
onTapDown: (d) => _waehleBei(d.localPosition.dy, raum.maxHeight),
onTapUp: (_) => setState(() => _beruehrt = null),
child: Semantics(
label: 'Schnellzugriff nach Anfangsbuchstabe',
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: MeloSpace.xs),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final buchstabe in widget.buchstaben)
Expanded(
child: Center(
child: Text(
buchstabe,
style: TextStyle(
fontSize: 11,
fontWeight: buchstabe == _beruehrt
? FontWeight.w700
: FontWeight.w500,
color: buchstabe == _beruehrt
? MeloTheme.red
: MeloTheme.text3,
),
),
),
),
],
),
),
),
),
),
);
}
}
+33
View File
@@ -0,0 +1,33 @@
/// Die Auswahl, nachdem [songId] angetippt wurde — ein Titel kommt dazu oder
/// fällt heraus.
///
/// Gibt eine neue Menge zurück statt die alte zu ändern: die Oberfläche
/// vergleicht beide, um zu erkennen, ob sich etwas getan hat.
Set<String> auswahlNach(Set<String> auswahl, String songId) {
final neu = {...auswahl};
if (!neu.remove(songId)) neu.add(songId);
return neu;
}
/// Beschriftung der Auswahl-Leiste.
String auswahlText(int anzahl) => switch (anzahl) {
0 => 'Titel auswählen',
1 => '1 Titel ausgewählt',
_ => '$anzahl ausgewählt',
};
/// Rückmeldung nach dem Hinzufügen mehrerer Titel zu einer Wiedergabeliste.
///
/// Nennt beides: was dazukam und was schon drin war. Vorher meldete die App
/// die volle Auswahl als „hinzugefügt", obwohl bereits enthaltene Titel wegen
/// des Primärschlüssels nur verschoben worden wären.
String playlistMeldung({
required int neu,
required int schonDa,
String? fehler,
}) {
if (fehler != null) return 'Abgebrochen nach $neu Titeln: $fehler';
if (neu == 0) return 'Alle schon in der Liste';
if (schonDa == 0) return '$neu Titel hinzugefügt';
return '$neu hinzugefügt · $schonDa waren schon drin';
}
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../library/database.dart';
import '../library/category_service.dart';
import '../library/playlist_service.dart';
import '../library/song_media.dart';
import '../player/audio_handler.dart';
import '../playlists/create_playlist_dialog.dart';
import '../services/sync_service.dart';
import '../settings/app_settings.dart';
import 'auswahl.dart';
import 'theme.dart';
/// Kopfzeile im Auswahl-Modus: Anzahl und die beiden Stapel-Aktionen.
///
/// Gemeinsam für alle Songlisten, die Mehrfachauswahl anbieten — sie ersetzt
/// die sonst dort stehende Zeile, statt darunter zu erscheinen.
class AuswahlLeiste extends StatelessWidget {
const AuswahlLeiste({
super.key,
required this.anzahl,
required this.onAbbrechen,
required this.onWiedergabeliste,
required this.onWarteschlange,
this.onServerLaden,
});
final int anzahl;
final VoidCallback onAbbrechen;
final VoidCallback onWiedergabeliste;
final VoidCallback onWarteschlange;
/// Nur gesetzt, wo der Upload hingehört („Meine Musik"). Sonst erschiene
/// die Aktion in allen fünf Ansichten, die diese Leiste benutzen.
final VoidCallback? onServerLaden;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.sm, MeloSpace.xs, MeloSpace.sm, MeloSpace.xs),
child: Row(
children: [
IconButton(
tooltip: 'Auswahl beenden',
icon: const Icon(Icons.close),
onPressed: onAbbrechen,
),
Expanded(
child: Text(
auswahlText(anzahl),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
if (onServerLaden != null)
IconButton(
tooltip: 'Auf den Server laden',
icon: const Icon(Icons.cloud_upload),
onPressed: onServerLaden,
),
IconButton(
tooltip: 'Zur Warteschlange hinzufügen',
icon: const Icon(Icons.queue),
onPressed: onWarteschlange,
),
IconButton(
tooltip: 'Zu Wiedergabeliste hinzufügen',
icon: const Icon(Icons.playlist_add),
onPressed: onWiedergabeliste,
),
],
),
);
}
}
/// Legt [gewaehlte] in eine (neue oder bestehende) Wiedergabeliste, mit
/// Rückmeldung über Neues/schon-Vorhandenes/Fehler.
Future<void> fuegeZuWiedergabelisteHinzu(
BuildContext context, List<Song> gewaehlte) async {
final db = context.read<MeloDb>();
final dienst = context.read<PlaylistService>();
final messenger = ScaffoldMessenger.of(context);
final ziel = await showModalBottomSheet<String>(
context: context,
builder: (sheetContext) => SafeArea(
child: StreamBuilder<List<Playlist>>(
stream: db.watchPlaylists(),
builder: (context, snapshot) {
final listen = snapshot.data ?? const <Playlist>[];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.add),
title: const Text('Neue Wiedergabeliste'),
onTap: () async {
final neu = await showDialog<String?>(
context: sheetContext,
builder: (_) => const CreatePlaylistDialog(),
);
if (neu != null && sheetContext.mounted) {
Navigator.pop(sheetContext, neu);
}
},
),
for (final liste in listen)
ListTile(
leading: const Icon(Icons.playlist_play),
title: Text(liste.name),
onTap: () => Navigator.pop(sheetContext, liste.id),
),
],
);
},
),
),
);
if (ziel == null) return;
final vorhanden = await db.watchPlaylistSongs(ziel).first;
// Wer schon drin ist, bleibt, wo er ist. Ein erneutes Einfügen würde ihn
// wegen des Primärschlüssels nur verschieben und Lücken hinterlassen.
final bekannt = {for (final s in vorhanden) s.id};
var position = vorhanden.length;
var neu = 0;
String? fehler;
for (final song in gewaehlte) {
if (bekannt.contains(song.id)) continue;
try {
await dienst.addSongToPlaylist(ziel, song.id, position++);
neu++;
} catch (e) {
fehler = '$e';
break;
}
}
if (!context.mounted) return;
messenger.showSnackBar(SnackBar(
content: Text(playlistMeldung(
neu: neu,
schonDa: gewaehlte.length - neu,
fehler: fehler,
)),
));
}
/// Hängt [gewaehlte] ans Ende der Warteschlange, mit Rückmeldung, falls
/// nicht alle Titel angehängt werden konnten (z. B. offline).
Future<void> fuegeZuWarteschlangeHinzu(
BuildContext context, List<Song> gewaehlte) async {
final handler = context.read<MeloAudioHandler>();
final categories = context.read<CategoryService>();
final settings = context.read<AppSettings>();
final messenger = ScaffoldMessenger.of(context);
var angehaengt = 0;
for (final song in gewaehlte) {
final ok = await handler.addToQueue(songToMediaItem(
song,
cover: categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory),
));
if (ok) angehaengt++;
}
if (!context.mounted) return;
messenger.showSnackBar(SnackBar(
content: Text(angehaengt == gewaehlte.length
? '$angehaengt Titel in der Warteschlange'
: '$angehaengt von ${gewaehlte.length} Titeln angehängt'),
));
}
/// Lädt [gewaehlte] zum Melo-Server und meldet das Ergebnis.
///
/// Wohnt neben [fuegeZuWiedergabelisteHinzu]: dieselbe Bauart, dieselbe Art
/// Rückmeldung.
Future<void> ladeAufServer(
BuildContext context, List<Song> gewaehlte) async {
final sync = context.read<SyncService>();
final messenger = ScaffoldMessenger.of(context);
final ergebnis = await sync.ladeAusgewaehlteHoch(gewaehlte);
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(sync.fehler ?? ergebnis.meldung)),
);
}
+42 -11
View File
@@ -2,9 +2,17 @@ import 'dart:io';
import 'package:flutter/material.dart';
import '../services/cover_cache.dart';
import 'theme.dart';
/// Cover-Bild aus einer file-URI, mit Noten-Platzhalter als Fallback.
/// Cover-Bild aus einer Datei- oder Server-Adresse, mit Noten-Platzhalter
/// als Fallback.
///
/// Der Server-Fall ist nicht nachträglich angeflanscht, sondern der Regelfall
/// für jeden Titel aus Navidrome: dessen Cover liegt hinter `getCoverArt.view`
/// und nicht als Datei auf dem Gerät. Vorher kannte diese Klasse nur `file://`
/// — jeder Server-Titel war deshalb ein graues Notensymbol, in der Liste wie
/// auf dem Sperrbildschirm.
class CoverImage extends StatelessWidget {
const CoverImage({
super.key,
@@ -35,16 +43,39 @@ class CoverImage extends StatelessWidget {
},
),
);
Widget child = placeholder;
if (artUri != null && artUri!.scheme == 'file') {
child = Image.file(
File(artUri!.toFilePath()),
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => placeholder,
);
}
Widget child = switch (artUri?.scheme) {
'file' => Image.file(
File(artUri!.toFilePath()),
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => placeholder,
),
// Über den Ordner statt direkt: einmal geladen, bleibt das Cover auch
// nach einem Neustart und ohne Netz da (siehe [CoverCache]). Solange
// es unterwegs ist, steht der Platzhalter — sonst springt die Zeile in
// der Liste, sobald das Bild ankommt.
'http' || 'https' => FutureBuilder<File?>(
// Schlüssel je Adresse: in einer Liste werden Zeilen samt ihrem
// Cover-Widget wiederverwendet. Ohne ihn behielte der FutureBuilder
// das Ergebnis der vorherigen Zeile und zeigte deren Bild, bis das
// neue geladen ist.
key: ValueKey(artUri),
future: CoverCache.hole(artUri!),
builder: (context, snapshot) {
final datei = snapshot.data;
if (datei == null) return placeholder;
return Image.file(
datei,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => placeholder,
);
},
),
_ => placeholder,
};
return ClipRRect(borderRadius: BorderRadius.circular(radius), child: child);
}
}
+12
View File
@@ -5,12 +5,24 @@ import '../library/playlist_service.dart';
import 'theme.dart';
/// Herz-Toggle: ausgefüllt = favorisiert, umrandet = nicht favorisiert.
///
/// Ohne [songId] (Titel, der nur am Server liegt) bleibt das Herz sichtbar,
/// aber ausgegraut. Vorher kam hier der leere String an: das Herz füllte sich,
/// die Favoritenliste blieb leer — sie verbindet über die Song-UUID, die ein
/// reiner Server-Titel nicht hat.
class FavoriteButton extends StatelessWidget {
const FavoriteButton({super.key, required this.songId});
final String songId;
@override
Widget build(BuildContext context) {
if (songId.isEmpty) {
return const IconButton(
tooltip: 'Nur für Titel in deiner Bibliothek',
icon: Icon(Icons.favorite_border, color: MeloTheme.text3),
onPressed: null,
);
}
final db = context.read<MeloDb>();
final service = context.read<PlaylistService>();
return StreamBuilder<bool>(
+175
View File
@@ -0,0 +1,175 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'theme.dart';
/// Drei tanzende Balken — das Zeichen dafür, dass genau dieser Titel läuft.
///
/// Bei [laeuft] `false` (pausiert) stehen sie still, statt zu verschwinden:
/// die Zeile bleibt als „hier bist du gerade" markiert, zeigt aber, dass
/// nichts vorangeht. Sind die Systemanimationen abgeschaltet, stehen sie
/// ebenfalls still — eine Endlosschleife ist genau das, was diese
/// Einstellung vermeiden soll.
class LaufBalken extends StatefulWidget {
const LaufBalken({
super.key,
required this.laeuft,
this.hoehe = 16,
this.farbe = Colors.white,
});
final bool laeuft;
final double hoehe;
final Color farbe;
@override
State<LaufBalken> createState() => _LaufBalkenState();
}
class _LaufBalkenState extends State<LaufBalken>
with SingleTickerProviderStateMixin {
static const _anzahl = 3;
/// Versatz je Balken, damit sie nicht im Gleichschritt springen.
static const _versatz = [0.0, 0.35, 0.7];
late final AnimationController _takt = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
// Kein _richteTaktAus() in initState: die Entscheidung hängt an MediaQuery,
// und darauf darf man dort noch nicht zugreifen. didChangeDependencies
// läuft unmittelbar danach und erledigt es.
@override
void didUpdateWidget(LaufBalken alt) {
super.didUpdateWidget(alt);
if (alt.laeuft != widget.laeuft) _richteTaktAus();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_richteTaktAus();
}
void _richteTaktAus() {
final ruhig = MediaQuery.disableAnimationsOf(context);
if (widget.laeuft && !ruhig) {
if (!_takt.isAnimating) _takt.repeat();
} else {
_takt.stop();
}
}
@override
void dispose() {
_takt.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: widget.hoehe,
width: widget.hoehe,
child: AnimatedBuilder(
animation: _takt,
builder: (context, _) => Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (var i = 0; i < _anzahl; i++)
_Balken(
anteil: _anteil(i),
hoehe: widget.hoehe,
farbe: widget.farbe,
),
],
),
),
);
}
/// Höhe eines Balkens zwischen 0,3 und 1,0. Stillstehend eine feste,
/// ungleiche Form — damit man auch pausiert erkennt, was gemeint ist.
double _anteil(int i) {
if (!_takt.isAnimating) return const [0.4, 1.0, 0.6][i];
final phase = (_takt.value + _versatz[i]) * 2 * math.pi;
return 0.3 + 0.7 * (0.5 + 0.5 * math.sin(phase));
}
}
class _Balken extends StatelessWidget {
const _Balken({
required this.anteil,
required this.hoehe,
required this.farbe,
});
final double anteil;
final double hoehe;
final Color farbe;
@override
Widget build(BuildContext context) {
return Container(
width: hoehe * 0.22,
height: hoehe * anteil,
decoration: BoxDecoration(
color: farbe,
borderRadius: BorderRadius.circular(hoehe * 0.11),
),
);
}
}
/// Das Coverbild einer Songzeile mit der Lauf-Markierung darüber.
///
/// Die Balken liegen **auf** dem Cover statt daneben: eine vierte Spalte in
/// der Zeile würde dem Titel Platz nehmen, und die Zeile spränge beim
/// Titelwechsel in der Breite.
class LaufMarkierung extends StatelessWidget {
const LaufMarkierung({
super.key,
required this.markiert,
required this.spielt,
required this.child,
this.radius = MeloRadius.element,
});
/// Dieser Titel steht in der Wiedergabe — auch pausiert.
final bool markiert;
/// Die Wiedergabe läuft tatsächlich (steuert nur die Bewegung).
final bool spielt;
final Widget child;
/// Muss zum Radius des Coverbilds darunter passen, sonst hat der Schleier
/// andere Ecken als das Bild.
final double radius;
@override
Widget build(BuildContext context) {
if (!markiert) return child;
return Stack(
alignment: Alignment.center,
children: [
child,
// Abdunkeln, damit die weißen Balken auch auf hellen Covern stehen.
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(radius),
),
),
),
LaufBalken(laeuft: spielt),
],
);
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import '../services/navidrome_service.dart';
import 'theme.dart';
/// Herz für einen Titel, der nur am Server liegt.
///
/// Solche Titel haben keine Zeile in der Bibliothek und damit keine UUID —
/// das gewöhnliche [FavoriteButton] muss sie deshalb ablehnen. Der Server
/// führt aber eine eigene Favoritenliste (`star`/`unstar`), und die ist
/// genau der richtige Ort dafür: von dort holt sie auch der
/// Favoriten-Abgleich in den Einstellungen.
///
/// Der Zustand wird bewusst nur lokal gehalten und nicht vom Server
/// nachgeladen: für ein einzelnes Herz wäre eine eigene Abfrage bei jedem
/// Titelwechsel zu teuer, und die Antwort träfe ohnehin erst nach dem
/// nächsten Tippen ein.
class ServerFavoriteButton extends StatefulWidget {
const ServerFavoriteButton({
super.key,
required this.navidromeId,
required this.navidrome,
});
final String navidromeId;
final NavidromeService navidrome;
@override
State<ServerFavoriteButton> createState() => _ServerFavoriteButtonState();
}
class _ServerFavoriteButtonState extends State<ServerFavoriteButton> {
bool? _favorit;
bool _laeuft = false;
Future<void> _umschalten() async {
if (_laeuft) return;
final neu = !(_favorit ?? false);
setState(() => _laeuft = true);
final messenger = ScaffoldMessenger.of(context);
final ok = neu
? await widget.navidrome.setFavorite(widget.navidromeId)
: await widget.navidrome.removeFavorite(widget.navidromeId);
if (!mounted) return;
setState(() {
_laeuft = false;
// Nur bei Erfolg umschalten — sonst zeigte das Herz einen Zustand, den
// der Server nicht kennt.
if (ok) _favorit = neu;
});
if (!ok) {
messenger.showSnackBar(
const SnackBar(content: Text('Der Server hat es nicht übernommen')),
);
}
}
@override
Widget build(BuildContext context) {
final favorit = _favorit ?? false;
return IconButton(
tooltip: favorit
? 'Favorit am Server entfernen'
: 'Am Server zu Favoriten hinzufügen',
icon: Icon(
favorit ? Icons.favorite : Icons.favorite_border,
color: favorit ? MeloTheme.red : MeloTheme.text2,
),
onPressed: _laeuft ? null : _umschalten,
);
}
}
+125 -4
View File
@@ -1,13 +1,16 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../library/category_service.dart';
import '../library/database.dart';
import '../library/song_list.dart';
import '../library/song_media.dart';
import '../library/category_service.dart';
import '../library/song_sort.dart';
import '../player/audio_handler.dart';
import '../settings/app_settings.dart';
import 'alphabet_leiste.dart';
import 'auswahl.dart';
import 'auswahl_leiste.dart';
import 'sort_store.dart';
import 'theme.dart';
@@ -20,6 +23,7 @@ class SortableSongList extends StatefulWidget {
required this.songs,
required this.storeKey,
this.empty,
this.serverUpload = false,
});
final List<Song> songs;
@@ -28,6 +32,10 @@ class SortableSongList extends StatefulWidget {
/// Wird statt der Liste gezeigt, wenn [songs] leer ist.
final Widget? empty;
/// Ob der Auswahl-Modus „Auf den Server laden" anbietet. Standard `false`:
/// dieses Widget steckt in fünf Ansichten, gemeint ist nur „Meine Musik".
final bool serverUpload;
@override
State<SortableSongList> createState() => _SortableSongListState();
}
@@ -38,6 +46,49 @@ class _SortableSongListState extends State<SortableSongList> {
ascending: false,
);
final _scroll = ScrollController();
/// Song-IDs der gerade ausgewählten Titel. Leer heißt: kein Auswahl-Modus.
Set<String> _auswahl = const {};
bool get _waehltAus => _auswahl.isNotEmpty;
void _wechsleAuswahl(String songId) =>
setState(() => _auswahl = auswahlNach(_auswahl, songId));
void _beendeAuswahl() => setState(() => _auswahl = const {});
/// Die ausgewählten Titel in der Reihenfolge der angezeigten Liste.
List<Song> _gewaehlte(List<Song> sorted) =>
[for (final s in sorted) if (_auswahl.contains(s.id)) s];
Future<void> _inWiedergabeliste(List<Song> gewaehlte) async {
await fuegeZuWiedergabelisteHinzu(context, gewaehlte);
if (mounted) _beendeAuswahl();
}
Future<void> _inWarteschlange(List<Song> gewaehlte) async {
await fuegeZuWarteschlangeHinzu(context, gewaehlte);
if (mounted) _beendeAuswahl();
}
Future<void> _aufServer(List<Song> gewaehlte) async {
await ladeAufServer(context, gewaehlte);
if (mounted) _beendeAuswahl();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
void _springeZu(int index) {
if (!_scroll.hasClients) return;
final ziel = index * songZeilenHoeheFuer(context);
_scroll.jumpTo(ziel.clamp(0.0, _scroll.position.maxScrollExtent));
}
@override
void initState() {
super.initState();
@@ -54,13 +105,18 @@ class _SortableSongListState extends State<SortableSongList> {
final messenger = ScaffoldMessenger.of(context);
final shuffled = [...widget.songs]..shuffle();
try {
await playSongs(
final uebersprungen = await playSongs(
handler,
shuffled,
0,
coverOf: (song) => categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory),
);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
);
}
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
@@ -82,6 +138,51 @@ class _SortableSongListState extends State<SortableSongList> {
await SortStore.save(widget.storeKey, chosen);
}
/// Die Liste, bei Namenssortierung mit Schnellzugriff daneben.
///
/// Nur dort: bei „Zuletzt hinzugefügt" oder „Wie oft abgespielt" stünden
/// die Buchstaben in willkürlicher Reihenfolge und führten ins Leere.
Widget _liste(List<Song> sorted) {
final index = _setting.mode == SortMode.name && sorted.length >= alphabetAb
? buchstabenIndex(sorted)
: const <String, int>{};
// Auch genug **verschiedene** Buchstaben: bei einem Album mit 40 Titeln
// „Track 01…40" gäbe es nur einen, und die Leiste wäre kein Sprungziel.
final mitLeiste = index.length >= alphabetMindestBuchstaben;
if (!mitLeiste) {
return SongList(
sorted,
controller: _scroll,
auswahl: _auswahl,
onAuswahlWechsel: _wechsleAuswahl,
);
}
// Der Index steht oben — er wird auf der **angezeigten** Liste gebaut,
// nicht auf einer gedachten aufsteigenden: bei ZA stehen die Buchstaben
// dann von unten nach oben in der Leiste, und ein Sprung landet am Anfang
// des Blocks statt an seinem Ende.
return Row(
children: [
Expanded(
child: SongList(
sorted,
controller: _scroll,
festeHoehe: true,
auswahl: _auswahl,
onAuswahlWechsel: _wechsleAuswahl,
),
),
AlphabetLeiste(
buchstaben: index.keys.toList(),
onGewaehlt: (b) {
final pos = index[b];
if (pos != null) _springeZu(pos);
},
),
],
);
}
@override
Widget build(BuildContext context) {
final sorted = sortSongs(
@@ -89,8 +190,27 @@ class _SortableSongListState extends State<SortableSongList> {
_setting.mode,
ascending: _setting.ascending,
);
return Column(
return PopScope(
// Zurück beendet zuerst die Auswahl. Sonst verlöre man zwanzig
// markierte Titel mit einer Geste, die überall sonst „einen Schritt
// zurück" bedeutet.
canPop: !_waehltAus,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && _waehltAus) _beendeAuswahl();
},
child: Column(
children: [
if (_waehltAus)
AuswahlLeiste(
anzahl: _auswahl.length,
onAbbrechen: _beendeAuswahl,
onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)),
onWarteschlange: () => _inWarteschlange(_gewaehlte(sorted)),
onServerLaden: widget.serverUpload
? () => _aufServer(_gewaehlte(sorted))
: null,
)
else
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 8, 4),
child: Row(
@@ -140,9 +260,10 @@ class _SortableSongListState extends State<SortableSongList> {
Expanded(
child: sorted.isEmpty
? (widget.empty ?? const SizedBox.shrink())
: SongList(sorted),
: _liste(sorted),
),
],
),
);
}
}
+36 -11
View File
@@ -13,6 +13,7 @@ class SubTabs extends StatelessWidget {
required this.labels,
required this.index,
required this.onChanged,
this.badges,
});
final List<String> labels;
@@ -20,6 +21,11 @@ class SubTabs extends StatelessWidget {
final int index;
final ValueChanged<int> onChanged;
/// Punkt neben dem Reiter-Text, an derselben Position je Eintrag wie
/// [labels] — z. B. "hier läuft im Hintergrund etwas". `null` oder `false`
/// zeigt nichts.
final List<bool>? badges;
@override
Widget build(BuildContext context) {
return Padding(
@@ -33,6 +39,7 @@ class SubTabs extends StatelessWidget {
child: _Reiter(
label: labels[i],
aktiv: i == index,
badge: badges != null && i < badges!.length && badges![i],
onTap: () => onChanged(i),
),
),
@@ -47,10 +54,12 @@ class _Reiter extends StatelessWidget {
required this.label,
required this.aktiv,
required this.onTap,
this.badge = false,
});
final String label;
final bool aktiv;
final bool badge;
final VoidCallback onTap;
@override
@@ -65,7 +74,7 @@ class _Reiter extends StatelessWidget {
child: InkWell(
onTap: onTap,
child: AnimatedContainer(
duration: MeloMotion.fast,
duration: MeloMotion.ruhig(context, MeloMotion.fast),
curve: MeloMotion.curve,
// Volle 48 dp hoch: darunter trifft man den Reiter im Gehen nicht
// zuverlässig (Material 3, Mindestgröße für Bedienelemente).
@@ -82,16 +91,32 @@ class _Reiter extends StatelessWidget {
// vorzudrängen — sonst schweben die Wörter im Nichts.
border: aktiv ? null : MeloTheme.hairline,
),
child: AnimatedDefaultTextStyle(
duration: MeloMotion.fast,
curve: MeloMotion.curve,
style: TextStyle(
color: aktiv ? MeloTheme.black : MeloTheme.text2,
fontSize: 15,
height: 1.2,
fontWeight: aktiv ? FontWeight.w600 : FontWeight.w500,
),
child: Text(label),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedDefaultTextStyle(
duration: MeloMotion.ruhig(context, MeloMotion.fast),
curve: MeloMotion.curve,
style: TextStyle(
color: aktiv ? MeloTheme.black : MeloTheme.text2,
fontSize: 15,
height: 1.2,
fontWeight: aktiv ? FontWeight.w600 : FontWeight.w500,
),
child: Text(label),
),
if (badge) ...[
const SizedBox(width: MeloSpace.xs),
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: MeloTheme.red,
shape: BoxShape.circle,
),
),
],
],
),
),
),
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import '../services/sync_service.dart';
/// „Willkommen zurück" — was sich seit dem letzten erfolgreichen Abgleich
/// getan hat. Rein in-App, ohne Benachrichtigungs-Kanal und ohne neue
/// Abhängigkeit.
class SyncBerichtDialog extends StatelessWidget {
const SyncBerichtDialog({super.key, required this.bericht});
final SyncBericht bericht;
static String textFuer(SyncBericht b) {
if (b.istLeer) return 'Nichts Neues seit dem letzten Abgleich.';
final teile = <String>[];
if (b.neueSongs > 0) teile.add('${b.neueSongs} neue Titel');
if (b.geloeschte > 0) teile.add('${b.geloeschte} entfernt');
if (b.favoriten > 0) teile.add('${b.favoriten} Favoriten geändert');
return '${teile.join(' · ')}.';
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Willkommen zurück!'),
content: Text(textFuer(bericht)),
actions: [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Alles klar'),
),
],
);
}
}
+63 -14
View File
@@ -46,6 +46,17 @@ abstract final class MeloMotion {
static const Duration normal = Duration(milliseconds: 220);
static const Curve curve = Curves.easeOutCubic;
/// [dauer], oder null, wenn der Nutzer Systemanimationen abgeschaltet hat.
///
/// Diese Einstellung nutzen Menschen, denen Bewegung auf dem Bildschirm
/// Unwohlsein bereitet. Die Übergänge sollen dann nicht schneller sein,
/// sondern gar nicht stattfinden — der Zustand wechselt schlicht.
///
/// Bewusst kein globaler Schalter im Theme: `MaterialApp` kennt keinen, und
/// jede animierte Stelle muss die Entscheidung ohnehin selbst treffen.
static Duration ruhig(BuildContext context, Duration dauer) =>
MediaQuery.disableAnimationsOf(context) ? Duration.zero : dauer;
}
/// Melo-Theme: Schwarz + Rot.
@@ -82,28 +93,53 @@ class MeloTheme {
/// Flächen sauberer als ein Schatten, den man ohnehin nicht sieht.
static Border get hairline => Border.all(color: border, width: 1);
/// Die Stufenleiter der Schrift.
///
/// Melo bleibt bei der System-Schrift (auf Android Roboto): sie ist immer
/// da, trägt jede Sprache und kostet nichts an App-Größe. Geschärft wird
/// stattdessen, was ohne eigene Schrift geht — Größenabstände, Gewichte
/// und vor allem die **Laufweite**: große Grade wirken mit den Standard-
/// abständen auseinandergezogen, Großbuchstaben dagegen gedrängt.
static TextTheme get _textTheme => const TextTheme(
// Bildschirm-Überschrift ("Meine Musik", "Online").
// Bildschirm-Überschrift ("Favoriten", "Download", "Suchen").
// Etwas enger gesetzt, damit die Zeile als ein Wortbild steht.
displaySmall: TextStyle(
fontSize: 28, height: 1.15, fontWeight: FontWeight.w700, color: text1),
fontSize: 26,
height: 1.15,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: text1),
// Titelleiste, Kopf eines Sheets.
headlineSmall: TextStyle(
fontSize: 22, height: 1.2, fontWeight: FontWeight.w700, color: text1),
fontSize: 22,
height: 1.2,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: text1),
// Hervorgehobene Zeile ("Shuffle-Wiedergabe").
titleMedium: TextStyle(
fontSize: 17, height: 1.3, fontWeight: FontWeight.w600, color: text1),
// Titel in einer Liste.
bodyLarge: TextStyle(fontSize: 15, height: 1.4, color: text1),
fontSize: 17,
height: 1.3,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
color: text1),
// Titel in einer Liste. Zeilenhöhe bleibt bei 1,4 — dass Fließtext
// nicht enger gesetzt wird, hält `theme_kontrast_test.dart` als
// Lesbarkeitsregel fest, und die ist älter als diese Politur.
bodyLarge: TextStyle(
fontSize: 15, height: 1.4, letterSpacing: 0, color: text1),
// Untertitel in einer Liste.
bodyMedium: TextStyle(fontSize: 13, height: 1.35, color: text2),
bodyMedium: TextStyle(
fontSize: 13, height: 1.35, letterSpacing: 0, color: text2),
// Beiwerk: Anzahl, Dauer.
bodySmall: TextStyle(fontSize: 12, height: 1.3, color: text3),
// Abschnitts-Beschriftung, Knopftext.
// Abschnitts-Beschriftung, Knopftext. Wird in Großbuchstaben
// gesetzt — und die brauchen mehr Luft, nicht weniger.
labelLarge: TextStyle(
fontSize: 12,
height: 1.2,
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
letterSpacing: 0.6,
color: text3),
);
@@ -127,8 +163,14 @@ class MeloTheme {
backgroundColor: black,
surfaceTintColor: Colors.transparent,
centerTitle: false,
// Dieselbe Stufe wie eine Überschrift — sonst hätte derselbe Rang
// zwei verschiedene Schriftbilder.
titleTextStyle: TextStyle(
fontSize: 22, height: 1.2, fontWeight: FontWeight.w700, color: text1),
fontSize: 22,
height: 1.2,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: text1),
),
listTileTheme: const ListTileThemeData(
contentPadding: EdgeInsets.symmetric(horizontal: MeloSpace.md),
@@ -167,14 +209,21 @@ class MeloTheme {
BorderRadius.vertical(top: Radius.circular(MeloRadius.sheet)),
),
),
// Das Hauptmenü. Es muss auf den ersten Blick als solches erkennbar
// sein — deshalb bewusst kräftiger als der übrige Beiwerk-Text:
// - text2 (72 %) statt der blassen dritten Stufe für die inaktiven
// Einträge; sie sollen lesbar sein, nicht nur erahnbar.
// - KEINE fontSize hier: damit gelten Flutters Vorgaben von 14 (aktiv)
// und 12 (inaktiv). Eine feste Größe für beide nahm dem aktiven
// Reiter genau den Größenunterschied, an dem man ihn erkennt.
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: surface,
selectedItemColor: red,
unselectedItemColor: text3,
unselectedItemColor: text2,
type: BottomNavigationBarType.fixed,
selectedLabelStyle:
TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
unselectedLabelStyle: TextStyle(fontSize: 12),
showUnselectedLabels: true,
selectedLabelStyle: TextStyle(fontWeight: FontWeight.w700),
unselectedLabelStyle: TextStyle(fontWeight: FontWeight.w500),
),
);
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import '../library/database.dart';
import 'sortable_song_list.dart';
/// Vollbild-Liste einer festen Titelmenge unter einer Überschrift — die
/// Ansicht hinter einem Künstler, einer Kategorie oder einem Suchabschnitt.
///
/// Vorher stand dieselbe Klasse zweimal privat im Baum (`_ArtistSongsScreen`,
/// `_KategorieLiederScreen`); die Suche hätte eine dritte Kopie gebraucht.
///
/// [SortableSongList] statt der nackten [SongList]: sonst bot diese Ansicht
/// weder Sortieren noch Mehrfachauswahl an, obwohl dieselbe Songzeile im
/// Lieder-Reiter beides kann — für den Nutzer wirkte langes Drücken hier wie
/// ein stiller Fehlschlag.
class TitelListenScreen extends StatelessWidget {
const TitelListenScreen({
super.key,
required this.titel,
required this.songs,
});
final String titel;
final List<Song> songs;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(titel)),
body: SortableSongList(songs: songs, storeKey: 'titel_liste'),
);
}
}
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
/// Überschrift eines Bildschirms ("Favoriten", "Download", "Suchen").
///
/// Nimmt die Stufe aus dem Theme ([TextTheme.displaySmall]) statt eigene
/// Werte zu setzen. Vorher stand in drei Bildschirmen dieselbe von Hand
/// gesetzte Größe, während die Stufe im Theme ungenutzt danebenlag — eine
/// Änderung dort wäre nirgends angekommen.
class Ueberschrift extends StatelessWidget {
const Ueberschrift(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text(text, style: Theme.of(context).textTheme.displaySmall),
),
);
}
}
@@ -11,6 +11,7 @@ import device_info_plus
import file_picker_darwin
import flutter_secure_storage_darwin
import just_audio
import package_info_plus
import record_macos
import shared_preferences_foundation
import sqflite_darwin
@@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
+16
View File
@@ -720,6 +720,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
url: "https://pub.dev"
source: hosted
version: "10.2.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
url: "https://pub.dev"
source: hosted
version: "4.1.0"
path:
dependency: "direct main"
description:
+1
View File
@@ -53,6 +53,7 @@ dependencies:
shared_preferences: ^2.2.2
flutter_secure_storage: ^11.0.0
record: ^7.1.1
package_info_plus: ^10.2.1
dev_dependencies:
flutter_test:
@@ -0,0 +1,129 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:melo/library/database.dart';
import 'package:melo/downloads/server_titel_screen.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/navidrome_service.dart';
/// Merkt sich nur, was verlangt wurde — echte Downloads brauchen einen
/// Server, und darum geht es hier nicht.
class _FakeDownloads extends DownloadService {
_FakeDownloads(MeloDb db) : super(db: db, navidrome: NavidromeService());
final geladen = <String>[];
final entfernt = <String>[];
@override
bool get laeuft => false;
@override
Future<bool> ladeEinzelnenTitel(SubsonicSong song) async {
geladen.add(song.id);
return true;
}
@override
Future<bool> entferne(String navidromeId) async {
entfernt.add(navidromeId);
return true;
}
}
void main() {
final titel = [
const SubsonicSong(
id: 'nav-1',
titel: 'Nachtpuls',
kuenstler: 'Rotklang',
album: 'Nacht',
dauerSekunden: 200,
),
];
Future<({_FakeDownloads dienst, MeloAudioHandler handler})> pumpe(
WidgetTester tester,
{bool schonGeladen = false}) async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
if (schonGeladen) {
// Der Bildschirm liest den Zustand einmal per db.downloadIds().
await db.merkeDownload(DownloadsCompanion.insert(
navidromeId: 'nav-1',
titel: 'Nachtpuls',
groesseBytes: 1,
geladenAmMs: 0,
));
}
final dienst = _FakeDownloads(db);
// MeloAudioHandler startet einen periodischen Timer (Positions-
// Buchführung). Ohne handler.dispose() meldet der Testrahmen ihn als
// „pending" — dasselbe etablierte Muster wie in
// test/library/my_music_screen_test.dart: handler.dispose() explizit am
// Testende aufrufen (addTearDown greift zu spät, außerhalb der
// FakeAsync-Zone, in der der Timer geprüft wird).
final handler = MeloAudioHandler(db: db);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<DownloadService>.value(value: dienst),
Provider<MeloAudioHandler>.value(value: handler),
],
child: MaterialApp(
home: ServerTitelScreen(
titel: 'Nacht',
navidrome: NavidromeService(),
holeTitel: () async => titel,
),
),
),
);
await tester.pumpAndSettle(
const Duration(milliseconds: 100),
EnginePhase.sendSemanticsUpdate,
const Duration(seconds: 5),
);
return (dienst: dienst, handler: handler);
}
testWidgets('ein einzelner Titel lässt sich offline nehmen', (tester) async {
final (:dienst, :handler) = await pumpe(tester);
await tester.tap(find.byTooltip('Offline nehmen'));
await tester.pumpAndSettle(
const Duration(milliseconds: 100),
EnginePhase.sendSemanticsUpdate,
const Duration(seconds: 5),
);
expect(dienst.geladen, ['nav-1']);
expect(dienst.entfernt, isEmpty);
handler.dispose();
});
testWidgets('ein schon geladener Titel bietet den Gegenweg an',
(tester) async {
final (:dienst, :handler) =
await pumpe(tester, schonGeladen: true);
// Zustand „schon offline": statt „Offline nehmen" steht dort das
// Entfernen — ohne diesen Test wäre der halbe Knopf ungeprüft.
expect(find.byTooltip('Offline nehmen'), findsNothing);
await tester.tap(find.byTooltip('Vom Gerät entfernen'));
await tester.pumpAndSettle(
const Duration(milliseconds: 100),
EnginePhase.sendSemanticsUpdate,
const Duration(seconds: 5),
);
expect(dienst.entfernt, ['nav-1']);
expect(dienst.geladen, isEmpty);
handler.dispose();
});
}
+83 -2
View File
@@ -10,6 +10,8 @@ import 'package:melo/downloads/downloads_screen.dart';
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/navidrome_service.dart';
import 'package:melo/services/yt_download_service.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -25,7 +27,9 @@ class _MemorySpeicher implements TokenSpeicher {
}
Widget _wrap(BakaAuth auth,
{required MeloDb db, required CategoryService categories}) {
{required MeloDb db,
required CategoryService categories,
NavidromeService? navidrome}) {
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
@@ -40,6 +44,12 @@ Widget _wrap(BakaAuth auth,
client: MockClient((_) async => http.Response('', 500)),
),
),
// Nur fürs Badge im Unterreiter-Kopf gebraucht (dienst.laeuft) — der
// Download-Tab beobachtet ihn app-weit, unabhängig vom Unterreiter.
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(
db: db, navidrome: navidrome ?? NavidromeService()),
),
],
child: const MaterialApp(home: Scaffold(body: DownloadsScreen())),
);
@@ -72,6 +82,14 @@ void main() {
});
}
// _YouTubeBereich prüft beim Öffnen per NavidromeService.
// ladeGespeicherteZugangsdaten(), ob ein Server-User verbunden ist — ohne
// diese Antwort bleibt der Aufruf im Test unbeantwortet hängen.
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
late MeloDb db;
late CategoryService categories;
@@ -87,7 +105,7 @@ void main() {
await db.close();
});
testWidgets('Online-Tab hat die Unterreiter YouTube und Server',
testWidgets('Download-Tab hat die Unterreiter YouTube und Server',
(tester) async {
final auth =
BakaAuth(client: MockClient((_) async => http.Response('', 500)),
@@ -189,4 +207,67 @@ void main() {
expect(find.byType(DropdownButtonFormField<String>), findsNothing);
});
testWidgets(
'Server-User sieht das Adressfeld sofort, ohne Baka-Anmeldung',
(tester) async {
final auth = BakaAuth(
client: MockClient(
(_) async => http.Response(jsonEncode({'token': 'jwt'}), 200)),
speicher: _MemorySpeicher(),
);
final nav = NavidromeService()
..setCredentials('https://musik.baka-net.de', 'Baka', 'geheim');
await tester.pumpWidget(_wrap(auth,
db: db, categories: categories, navidrome: nav));
await tester.pumpAndSettle();
expect(find.text('Beim Baka-Konto anmelden'), findsNothing);
expect(find.text('Angemeldet als Baka'), findsNothing);
expect(find.text('YouTube-Adresse'), findsOneWidget);
});
testWidgets(
'Server-User bekommt bei fehlgeschlagener Auto-Anmeldung einen '
'Rückfallweg zur manuellen Anmeldung', (tester) async {
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final nav = NavidromeService()
..setCredentials('https://musik.baka-net.de', 'Baka', 'falsch');
await tester.pumpWidget(_wrap(auth,
db: db, categories: categories, navidrome: nav));
await tester.pumpAndSettle();
expect(find.text('Beim Baka-Konto anmelden'), findsOneWidget);
expect(find.text('YouTube-Adresse'), findsNothing);
expect(
find.textContaining('automatische Anmeldung hat nicht geklappt'),
findsOneWidget);
});
testWidgets(
'Server-User meldet sich im Hintergrund mit denselben Zugangsdaten '
'beim Baka-Konto an', (tester) async {
String? gesendeterBenutzer;
final auth = BakaAuth(
client: MockClient((request) async {
final body = jsonDecode(request.body) as Map<String, dynamic>;
gesendeterBenutzer = body['username'] as String?;
return http.Response(jsonEncode({'token': 'jwt'}), 200);
}),
speicher: _MemorySpeicher(),
);
final nav = NavidromeService()
..setCredentials('https://musik.baka-net.de', 'Tinker', 'geheim2');
await tester.pumpWidget(_wrap(auth,
db: db, categories: categories, navidrome: nav));
await tester.pumpAndSettle();
expect(gesendeterBenutzer, 'Tinker');
expect(auth.istAngemeldet, isTrue);
});
}
@@ -0,0 +1,185 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:melo/downloads/youtube_search_screen.dart';
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/navidrome_service.dart';
import 'package:melo/services/yt_download_service.dart';
import 'package:melo/services/yt_search_service.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);
}
Future<BakaAuth> _angemeldeteAuth() async {
final auth = BakaAuth(
client: MockClient(
(_) async => http.Response(jsonEncode({'token': 'jwt-abc'}), 200)),
speicher: _MemorySpeicher(),
);
await auth.anmelden('Baka', 'geheim');
return auth;
}
Widget _wrap({
required MeloDb db,
required BakaAuth auth,
required YtSearchService search,
required YtDownloadService download,
NavidromeService? navidrome,
}) {
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: LibraryService(db)),
ChangeNotifierProvider<BakaAuth>.value(value: auth),
ChangeNotifierProvider<YtSearchService>.value(value: search),
ChangeNotifierProvider<YtDownloadService>.value(value: download),
// Nur für die Server-User-Prüfung gebraucht (siehe
// _pruefeServerUser) — ohne gespeicherte Zugangsdaten bleibt sie ein
// Gast.
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(
db: db, navidrome: navidrome ?? NavidromeService()),
),
],
child: const MaterialApp(home: Scaffold(body: YoutubeSearchScreen())),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
// _pruefeServerUser prüft per NavidromeService.ladeGespeicherteZugangs-
// daten(), ob ein Server-User verbunden ist — ohne diese Antwort bleibt
// der Aufruf im Test unbeantwortet hängen.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
testWidgets('ohne Anmeldung erscheint der Hinweis auf die Baka-Anmeldung',
(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);
await tester.pumpWidget(_wrap(
db: db, auth: auth, search: search, download: download));
await tester.pumpAndSettle();
expect(find.textContaining('Baka-Anmeldung'), findsOneWidget);
expect(find.text('Nach Musikvideos suchen'), findsOneWidget);
await db.close();
});
testWidgets('Server-User sieht keinen Anmelde-Hinweis', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final auth = BakaAuth(
client: MockClient(
(_) async => http.Response(jsonEncode({'token': 'jwt'}), 200)),
speicher: _MemorySpeicher(),
);
final search = YtSearchService(auth: auth);
final download = YtDownloadService(auth: auth);
final nav = NavidromeService()
..setCredentials('https://musik.baka-net.de', 'Dustin', 'geheim');
await tester.pumpWidget(_wrap(
db: db, auth: auth, search: search, download: download,
navidrome: nav));
await tester.pumpAndSettle();
expect(find.textContaining('Baka-Anmeldung'), findsNothing);
await db.close();
});
testWidgets(
'Server-User bekommt bei fehlgeschlagener Auto-Anmeldung wieder '
'den Anmelde-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 nav = NavidromeService()
..setCredentials('https://musik.baka-net.de', 'Dustin', 'falsch');
await tester.pumpWidget(_wrap(
db: db, auth: auth, search: search, download: download,
navidrome: nav));
await tester.pumpAndSettle();
expect(find.textContaining('Baka-Anmeldung'), findsOneWidget);
await db.close();
});
testWidgets('Suche zeigt Titel und Dauer der Treffer', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final auth = await _angemeldeteAuth();
final search = YtSearchService(
auth: auth,
client: MockClient((req) async {
expect(req.url.queryParameters['q'], 'roses');
return http.Response(
jsonEncode([
{
'title': 'Neoni - Roses',
'url': 'https://youtu.be/dQw4w9WgXcQ',
'duration': 187,
},
]),
200);
}),
);
final download = YtDownloadService(auth: auth);
await tester.pumpWidget(_wrap(
db: db, auth: auth, search: search, download: download));
await tester.enterText(find.byType(TextField), 'roses');
await tester.tap(find.text('Suchen'));
await tester.pumpAndSettle();
expect(find.text('Neoni - Roses'), findsOneWidget);
expect(find.text('3:07'), findsOneWidget);
expect(find.byIcon(Icons.download_for_offline_outlined), 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
// (auch ganz ohne dieses Widget reproduzierbar) und lässt den Testlauf
// entweder hängen oder an der Pending-Timer-Prüfung scheitern. Aus
// demselben Grund hat auch der bestehende Download-Knopf in
// _YouTubeBereich (downloads_screen.dart) keinen eigenen Widget-Test —
// die Download-Logik selbst ist über yt_download_test.dart ausführlich
// abgedeckt.
}
+223
View File
@@ -0,0 +1,223 @@
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/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/navidrome_service.dart';
import 'package:melo/services/offline_mode.dart';
import 'package:melo/services/sync_service.dart';
import 'package:melo/services/yt_download_service.dart';
import 'package:melo/services/yt_search_service.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:melo/shared/theme.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);
}
/// Die fünf Bereiche des Hauptmenüs, in dieser Reihenfolge.
const hauptmenue = [
'Meine Musik',
'Suchen',
'Download',
'Favoriten',
'YT-Suche'
];
void main() {
late MeloDb db;
late CategoryService kategorien;
late LibraryService bibliothek;
late PlaylistService listen;
late BakaAuth auth;
late AppSettings einstellungen;
late OfflineMode offline;
late YtDownloadService ytDownload;
late YtSearchService ytSearch;
late DownloadService downloads;
late SyncService sync;
MeloAudioHandler? handler;
setUp(() {
SharedPreferences.setMockInitialValues({});
db = MeloDb(NativeDatabase.memory());
kategorien = CategoryService(db);
bibliothek = LibraryService(db);
listen = PlaylistService(db);
auth = BakaAuth(speicher: _MemorySpeicher());
einstellungen = AppSettings();
offline = OfflineMode();
ytDownload = YtDownloadService(auth: auth);
ytSearch = YtSearchService(auth: auth);
downloads = DownloadService(db: db, navidrome: NavidromeService());
sync = SyncService(db: db, cloud: MeloCloudService(auth: auth));
});
tearDown(() {
kategorien.dispose();
handler?.dispose();
// Bewusst kein db.close(): drift plant beim Schließen eine Aufräum-
// Aufgabe ein, die im Test-Rahmen nach einem tester.tap nie mehr an die
// Reihe kommt — der Lauf hinge dann unbegrenzt. Die Datenbank liegt nur
// im Arbeitsspeicher und verschwindet mit dem Testprozess.
});
// Alle Dienste gehören dem Test und werden per .value gereicht. Würde der
// 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() {
// Der Audio-Handler erst hier: sein Zwischenspeicher greift auf einen
// Plattform-Kanal zu, den es im setUp noch nicht gibt.
handler ??= MeloAudioHandler(db: db);
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: bibliothek),
ChangeNotifierProvider<PlaylistService>.value(value: listen),
Provider<MeloAudioHandler>.value(value: handler!),
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<OfflineMode>.value(value: offline),
ChangeNotifierProvider<BakaAuth>.value(value: auth),
ChangeNotifierProvider<YtDownloadService>.value(value: ytDownload),
ChangeNotifierProvider<YtSearchService>.value(value: ytSearch),
ChangeNotifierProvider<DownloadService>.value(value: downloads),
ChangeNotifierProvider<SyncService>.value(value: sync),
],
child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()),
);
}
/// Baut den Baum ab und beendet die laufenden Timer.
///
/// Muss im Testkörper passieren, nicht im tearDown: der Positions-Timer
/// des Audio-Handlers läuft periodisch, und der Test-Rahmen prüft am Ende
/// des Körpers, ob noch Timer offen sind.
Future<void> abbauen(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
handler?.dispose();
handler = 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
// einen offenen Timer.
await tester.pump(const Duration(milliseconds: 1));
}
Finder eintrag(String name) => find.descendant(
of: find.byType(BottomNavigationBar),
matching: find.text(name),
);
testWidgets('alle vier Bereiche stehen im Hauptmenü', (tester) async {
await tester.pumpWidget(bauen());
await tester.pump();
final leiste =
tester.widget<BottomNavigationBar>(find.byType(BottomNavigationBar));
expect(leiste.items.map((i) => i.label).toList(), hauptmenue);
await abbauen(tester);
});
testWidgets('jeder Bereich ist beschriftet und liegt auf dem Bildschirm',
(tester) async {
await tester.pumpWidget(bauen());
await tester.pump();
final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio;
for (final name in hauptmenue) {
expect(eintrag(name), findsOneWidget, reason: '"$name" fehlt im Menü');
// Vorhanden genügt nicht — ein Eintrag außerhalb des Bildschirms oder
// ohne Ausdehnung wäre für den Nutzer nicht da.
final kasten = tester.getRect(eintrag(name));
expect(kasten.width, greaterThan(0), reason: '"$name" ohne Breite');
expect(kasten.height, greaterThan(0), reason: '"$name" ohne Höhe');
expect(kasten.bottom, lessThanOrEqualTo(bildschirm.height),
reason: '"$name" liegt unterhalb des Bildschirms');
expect(kasten.top, greaterThanOrEqualTo(0),
reason: '"$name" liegt oberhalb des Bildschirms');
}
await abbauen(tester);
});
for (var i = 1; i < hauptmenue.length; i++) {
final ziel = i;
testWidgets('Tipp auf "${hauptmenue[ziel]}" wechselt dorthin',
(tester) async {
await tester.pumpWidget(bauen());
await tester.pump();
await tester.tap(eintrag(hauptmenue[ziel]));
await tester.pump();
expect(
tester
.widget<BottomNavigationBar>(find.byType(BottomNavigationBar))
.currentIndex,
ziel,
);
await abbauen(tester);
});
}
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.pump();
final menue = tester.getRect(find.byType(BottomNavigationBar));
final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio;
expect(menue.bottom, closeTo(bildschirm.height, 1));
await abbauen(tester);
});
group('Darstellung des Hauptmenüs', () {
// Reine Theme-Prüfungen — kein Widget-Baum nötig.
final menue = MeloTheme.dark.bottomNavigationBarTheme;
test('der aktive Bereich hebt sich ab — nicht nur durch Farbe', () {
// Wer Rot schlecht sieht, muss trotzdem erkennen, wo er ist.
expect(menue.selectedItemColor, MeloTheme.red);
expect(menue.selectedLabelStyle!.fontWeight, FontWeight.w700);
expect(menue.unselectedLabelStyle!.fontWeight, FontWeight.w500);
});
test('keine feste Schriftgröße — sonst verschwindet der Unterschied', () {
// Ohne Angabe gelten Flutters 14 (aktiv) und 12 (inaktiv). Beide auf
// 12 zu setzen nahm dem aktiven Reiter genau dieses Merkmal.
expect(menue.selectedLabelStyle!.fontSize, isNull);
expect(menue.unselectedLabelStyle!.fontSize, isNull);
});
test('die inaktiven Einträge sind lesbar, nicht nur erahnbar', () {
// text3 ist die Beiwerk-Stufe. Ein Hauptmenü ist kein Beiwerk.
expect(menue.unselectedItemColor, MeloTheme.text2);
});
test('die Beschriftungen bleiben auch bei inaktiven Einträgen stehen', () {
// Ohne sie müsste man die Symbole raten.
expect(menue.showUnselectedLabels, isTrue);
});
});
}
+9 -2
View File
@@ -8,10 +8,13 @@ import 'package:melo/library/playlist_service.dart';
import 'package:melo/main.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/navidrome_service.dart';
import 'package:melo/services/offline_mode.dart';
import 'package:melo/services/sync_service.dart';
import 'package:melo/services/yt_download_service.dart';
import 'package:melo/services/yt_search_service.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -30,7 +33,7 @@ void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets('Untere Navigation folgt der Referenz-UI: '
'Meine Musik, Online, Suchen, Favoriten', (tester) async {
'Meine Musik, Suchen, Download, Favoriten, YT-Suche', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
@@ -49,6 +52,10 @@ void main() {
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<YtSearchService>(
create: (_) => YtSearchService(auth: auth)),
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
ChangeNotifierProvider<SyncService>(
create: (_) => SyncService(
db: db, cloud: MeloCloudService(auth: auth))),
@@ -60,7 +67,7 @@ void main() {
final bar = tester.widget<BottomNavigationBar>(
find.byType(BottomNavigationBar));
expect(bar.items.map((i) => i.label).toList(),
['Meine Musik', 'Online', 'Suchen', 'Favoriten']);
['Meine Musik', 'Suchen', 'Download', 'Favoriten', 'YT-Suche']);
handler.dispose();
await db.close();
@@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/permissions.dart';
/// Ohne POST_NOTIFICATIONS zeigt Android 13+ die Wiedergabe-Benachrichtigung
/// nicht an — und damit fehlt die wichtigste Bedienfläche einer Musik-App.
/// Vor Android 13 gab es das Recht noch nicht; dort darf nicht gefragt werden.
void main() {
group('brauchtBenachrichtigungsRecht', () {
test('Android 13 (API 33) braucht das Recht', () {
expect(
brauchtBenachrichtigungsRecht(istAndroid: true, sdkInt: 33),
isTrue,
);
});
test('Android 14 (API 34) braucht das Recht', () {
expect(
brauchtBenachrichtigungsRecht(istAndroid: true, sdkInt: 34),
isTrue,
);
});
test('Android 12 (API 32) fragt nicht — das Recht gibt es dort nicht', () {
expect(
brauchtBenachrichtigungsRecht(istAndroid: true, sdkInt: 32),
isFalse,
);
});
test('außerhalb von Android wird nie gefragt', () {
expect(
brauchtBenachrichtigungsRecht(istAndroid: false, sdkInt: 33),
isFalse,
);
});
});
}
@@ -0,0 +1,17 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/artist_list.dart';
/// Die Künstlerzeile war vorher nackter Text ohne Angabe zu den Alben.
void main() {
test('nennt Titel und Alben', () {
expect(kuenstlerUntertitel(12, 3), '12 Titel · 3 Alben');
});
test('ein einzelnes Album wird nicht erwähnt — es wäre keine Information', () {
expect(kuenstlerUntertitel(12, 1), '12 Titel');
});
test('ohne Album bleibt es bei den Titeln', () {
expect(kuenstlerUntertitel(3, 0), '3 Titel');
});
}
+51 -2
View File
@@ -14,7 +14,7 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
Widget _wrap(MeloDb db, LibraryService lib, PlaylistService playlists,
MeloAudioHandler handler, Widget child) {
MeloAudioHandler handler, Widget child, {AppSettings? settings}) {
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
@@ -22,7 +22,9 @@ Widget _wrap(MeloDb db, LibraryService lib, PlaylistService playlists,
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
settings == null
? ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings())
: ChangeNotifierProvider<AppSettings>.value(value: settings),
],
child: MaterialApp(home: Scaffold(body: child)),
);
@@ -112,6 +114,31 @@ void main() {
await db.close();
});
testWidgets(
'abgewählte Sektionen fehlen in den Schnellzugriffen, aktive bleiben',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final settings = AppSettings();
await settings.init();
await settings.setMeineMusikSektionAktiv(
AppSettings.sektionWiedergabelisten, false);
await tester.pumpWidget(_wrap(
db, lib, playlists, handler, const MyMusicScreen(),
settings: settings));
await tester.pumpAndSettle();
expect(find.text('Favoriten'), findsOneWidget);
expect(find.text('Zuletzt'), findsOneWidget);
expect(find.text('Wiedergabelisten'), findsNothing);
handler.dispose();
await db.close();
});
testWidgets('Favoriten-Tab zeigt nur favorisierte Songs mit Shuffle',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
@@ -150,4 +177,26 @@ void main() {
handler.dispose();
await db.close();
});
testWidgets(
'Banner mit "Einstellungen"-Button, wenn die Benachrichtigung verweigert ist',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db)..notificationPermissionDenied = true;
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
await tester.pumpWidget(
_wrap(db, lib, playlists, handler, const MyMusicScreen()));
await tester.pumpAndSettle();
expect(
find.textContaining('Benachrichtigungen für die Wiedergabe'),
findsOneWidget,
);
expect(find.text('Einstellungen'), findsOneWidget);
handler.dispose();
await db.close();
});
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:drift/drift.dart' show Migrator;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/database.dart';
void main() {
test('Bestandsdaten überleben die neue Spalte', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
// Den Stand von Schema 10 nachbauen: Tabelle ohne cloud_id, mit Daten.
await db.customStatement('DROP TABLE playlist_songs');
await db.customStatement('DROP TABLE playlists');
await db.customStatement(
'CREATE TABLE playlists ('
'id TEXT NOT NULL, '
'name TEXT NOT NULL, '
'description TEXT NULL, '
'created_at_ms INTEGER NOT NULL, '
'updated_at_ms INTEGER NOT NULL, '
'deleted INTEGER NOT NULL DEFAULT 0, '
'PRIMARY KEY (id))',
);
await db.customStatement(
"INSERT INTO playlists (id, name, created_at_ms, updated_at_ms) "
"VALUES ('alt-1', 'Road Trip', 0, 0)",
);
await Migrator(db).addColumn(db.playlists, db.playlists.cloudId);
final rows = await db.select(db.playlists).get();
expect(rows.single.name, 'Road Trip');
expect(rows.single.cloudId, isNull);
});
test('setPlaylistCloudId merkt sich die Server-ID', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
final id = await db.createPlaylist('Mix');
await db.setPlaylistCloudId(id, '42');
expect((await db.playlistById(id))!.cloudId, '42');
});
test('countPlaylists zählt auch Grabsteine', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
final id = await db.createPlaylist('Mix');
await db.deletePlaylist(id);
// Sonst hielte die Wiederherstellung ein Gerät, auf dem der Nutzer alle
// Playlisten gelöscht hat, für eine Neuinstallation.
expect(await db.countPlaylists(), 1);
});
}
+131
View File
@@ -1,7 +1,32 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/melo_cloud_service.dart';
class _MemorySpeicher implements TokenSpeicher {
_MemorySpeicher(this.werte);
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);
}
Future<BakaAuth> _angemeldeteAuth() async {
final auth = BakaAuth(
speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}),
);
await auth.laden();
return auth;
}
void main() {
late MeloDb db;
@@ -61,4 +86,110 @@ void main() {
expect(songs.map((s) => s.id).toList(), ['song-3', 'song-1', 'song-2']);
expect(notified, true);
});
group('Sofort-Push der Favoriten', () {
Future<void> legeSongAn(MeloDb db, {String? cloudId}) async {
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1',
path: '/a.mp3',
title: 'A',
dateAddedMs: 0,
updatedAtMs: 0,
));
if (cloudId != null) await db.setCloudId('song-1', cloudId);
}
test('setzt deterministisch true beim Favorisieren', () async {
final db2 = MeloDb(NativeDatabase.memory());
addTearDown(db2.close);
await legeSongAn(db2, cloudId: 'c1');
final gesendet = <Map<String, dynamic>>[];
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((anfrage) async {
gesendet.add(jsonDecode(anfrage.body) as Map<String, dynamic>);
return http.Response(jsonEncode({'status': 'ok'}), 200);
}),
),
);
await dienst.toggleFavorite('song-1');
expect(gesendet, [
{'song_id': 'c1', 'set': true}
]);
});
test('setzt deterministisch false beim Ent-Favorisieren', () async {
final db2 = MeloDb(NativeDatabase.memory());
addTearDown(db2.close);
await legeSongAn(db2, cloudId: 'c1');
await db2.setFavorite('song-1', true);
final gesendet = <Map<String, dynamic>>[];
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((anfrage) async {
gesendet.add(jsonDecode(anfrage.body) as Map<String, dynamic>);
return http.Response(jsonEncode({'status': 'ok'}), 200);
}),
),
);
await dienst.toggleFavorite('song-1');
expect(gesendet, [
{'song_id': 'c1', 'set': false}
]);
});
test('ohne cloudId wird nichts gemeldet', () async {
final db2 = MeloDb(NativeDatabase.memory());
addTearDown(db2.close);
await legeSongAn(db2);
var anfragen = 0;
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((_) async {
anfragen++;
return http.Response(jsonEncode({'status': 'ok'}), 200);
}),
),
);
await dienst.toggleFavorite('song-1');
expect(anfragen, 0);
expect(await db2.watchIsFavorite('song-1').first, isTrue);
});
test('ein Fehler des Servers ändert lokal nichts und wirft nicht',
() async {
final db2 = MeloDb(NativeDatabase.memory());
addTearDown(db2.close);
await legeSongAn(db2, cloudId: 'c1');
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient(
(_) async => http.Response(jsonEncode({'error': 'weg'}), 500)),
),
);
// Der nächste Voll-Abgleich holt den Push additiv nach.
await dienst.toggleFavorite('song-1');
expect(await db2.watchIsFavorite('song-1').first, isTrue);
});
});
}
+254
View File
@@ -0,0 +1,254 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/melo_cloud_service.dart';
class _MemorySpeicher implements TokenSpeicher {
_MemorySpeicher(this.werte);
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);
}
Future<MeloCloudService> cloudMit(
Future<http.Response> Function(http.Request) antwort) async {
final auth = BakaAuth(
speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}),
);
await auth.laden();
return MeloCloudService(auth: auth, client: MockClient(antwort));
}
void main() {
test('eine neue Playlist wird gemeldet und ihre Server-ID gemerkt',
() async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
final dienst = PlaylistService(
db,
cloud: await cloudMit((_) async => http.Response(
// Form von handle_playlist_create: die ID liegt unter „playlist".
jsonEncode({
'status': 'ok',
'playlist': {'id': 7, 'name': 'Road Trip'}
}),
200,
)),
);
final id = await dienst.createPlaylist('Road Trip');
expect((await db.playlistById(id))!.cloudId, '7');
});
test('ein Song ohne cloudId wird nicht mitgemeldet', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
var songMeldungen = 0;
final dienst = PlaylistService(
db,
cloud: await cloudMit((anfrage) async {
if (anfrage.url.path.endsWith('/songs')) songMeldungen++;
return http.Response(
jsonEncode({
'status': 'ok',
'playlist': {'id': 7, 'name': 'Mix'}
}),
200,
);
}),
);
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1', path: '/a.mp3', title: 'A',
dateAddedMs: 0, updatedAtMs: 0,
));
final id = await dienst.createPlaylist('Mix');
await dienst.addSongToPlaylist(id, 'song-1', 0);
expect(songMeldungen, 0);
// Lokal ist er trotzdem drin — kein Fehler, nur nichts zu melden.
expect(await db.watchPlaylistSongs(id).first, hasLength(1));
});
/// Legt einen Titel mit Server-ID an. Ohne den fällt jeder Push aus, und
/// die Negativtests allein hätten den ganzen Vertrag nie berührt.
Future<void> legeSongAn(MeloDb db, String id, String cloudId) async {
await db.into(db.songs).insert(SongsCompanion.insert(
id: id, path: '/$id.mp3', title: id,
dateAddedMs: 0, updatedAtMs: 0,
));
await db.setCloudId(id, cloudId);
}
test('ein Song mit cloudId wird an die Server-Playlist gemeldet', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
Object? koerper;
String? pfad;
final dienst = PlaylistService(
db,
cloud: await cloudMit((anfrage) async {
if (anfrage.method == 'POST' && anfrage.url.path.endsWith('/songs')) {
pfad = anfrage.url.path;
koerper = jsonDecode(anfrage.body);
}
return http.Response(
jsonEncode({
'status': 'ok',
'playlist': {'id': 7, 'name': 'Mix'}
}),
200,
);
}),
);
await legeSongAn(db, 'song-1', 'c1');
final id = await dienst.createPlaylist('Mix');
await dienst.addSongToPlaylist(id, 'song-1', 0);
expect(pfad, endsWith('/playlists/7/songs'));
expect(koerper, {
'song_ids': ['c1']
});
});
test('das Entfernen geht als DELETE auf den Song-Pfad', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
String? geloeschterPfad;
final dienst = PlaylistService(
db,
cloud: await cloudMit((anfrage) async {
if (anfrage.method == 'DELETE') geloeschterPfad = anfrage.url.path;
return http.Response(
jsonEncode({
'status': 'ok',
'playlist': {'id': 7, 'name': 'Mix'}
}),
200,
);
}),
);
await legeSongAn(db, 'song-1', 'c1');
final id = await dienst.createPlaylist('Mix');
await dienst.addSongToPlaylist(id, 'song-1', 0);
await dienst.removeSongFromPlaylist(id, 'song-1');
expect(geloeschterPfad, endsWith('/playlists/7/songs/c1'));
expect(await db.watchPlaylistSongs(id).first, isEmpty);
});
test('eine neue Reihenfolge geht als positions-Paare raus', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
Object? koerper;
String? methode;
final dienst = PlaylistService(
db,
cloud: await cloudMit((anfrage) async {
if (anfrage.url.path.endsWith('/positions')) {
methode = anfrage.method;
koerper = jsonDecode(anfrage.body);
}
return http.Response(
jsonEncode({
'status': 'ok',
'playlist': {'id': 7, 'name': 'Mix'}
}),
200,
);
}),
);
await legeSongAn(db, 'song-1', 'c1');
await legeSongAn(db, 'song-2', 'c2');
final id = await dienst.createPlaylist('Mix');
await dienst.addSongToPlaylist(id, 'song-1', 0);
await dienst.addSongToPlaylist(id, 'song-2', 1);
await dienst.reorderAll(id, ['song-2', 'song-1']);
// Unter „song_ids" hätte der Server eine leere Liste gelesen und stumm
// „ok" geantwortet — dieser Test ist der einzige Ort, an dem das auffällt.
expect(methode, 'PUT');
expect(koerper, {
'positions': [
{'id': 'c2', 'position': 0},
{'id': 'c1', 'position': 1},
]
});
});
test('ein Endpunkt-Fehler ändert lokal nichts', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
final dienst = PlaylistService(
db,
cloud: await cloudMit(
(_) async => http.Response(jsonEncode({'error': 'weg'}), 500)),
);
final id = await dienst.createPlaylist('Mix');
expect((await db.playlistById(id))!.cloudId, isNull);
expect(await db.watchPlaylists().first, hasLength(1));
});
test('Wiederherstellung greift nur bei leerer Playlisten-Tabelle', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
final dienst = PlaylistService(
db,
cloud: await cloudMit((anfrage) async {
if (anfrage.url.path.endsWith('/playlists')) {
return http.Response(
jsonEncode({
'playlists': [
{'id': 7, 'name': 'Vom Server'}
]
}),
200,
);
}
return http.Response(jsonEncode({'songs': []}), 200);
}),
);
expect(await dienst.stelleWiederHer(), 1);
final angelegt = await db.watchPlaylists().first;
expect(angelegt.single.name, 'Vom Server');
expect(angelegt.single.cloudId, '7');
});
test('mit einer lokalen Playlist wird nichts angelegt (kein Rück-Merge)',
() async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
final dienst = PlaylistService(
db,
cloud: await cloudMit((_) async => http.Response(
jsonEncode({
'playlists': [
{'id': 7, 'name': 'Vom Server'}
]
}),
200,
)),
);
await db.createPlaylist('Meine eigene');
expect(await dienst.stelleWiederHer(), 0);
expect(await db.watchPlaylists().first, hasLength(1));
});
}
+171
View File
@@ -0,0 +1,171 @@
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:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/library/search_screen.dart';
import 'package:melo/settings/app_settings.dart';
/// Die Suche zeigte früher eine flache Liederliste. Jetzt trennt sie die
/// Treffer nach Art — dieser Test prüft, dass die Abschnitte erscheinen und
/// nur die passenden Treffer enthalten.
void main() {
late MeloDb db;
late CategoryService kategorien;
late AppSettings einstellungen;
late PlaylistService playlists;
setUp(() async {
// Jeder Test legt eine eigene In-Memory-Datenbank an; die Warnung über
// mehrere Instanzen ist hier erwartet und kein Befund.
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
db = MeloDb(NativeDatabase.memory());
kategorien = CategoryService(db);
playlists = PlaylistService(db);
einstellungen = AppSettings();
await einstellungen.init();
Future<void> song(String id, String titel, String kuenstler) =>
db.into(db.songs).insert(SongsCompanion.insert(
id: id,
path: '/music/$id.mp3',
title: titel,
artist: Value(kuenstler),
dateAddedMs: 0,
updatedAtMs: 0,
));
await song('s1', 'Roses', 'Neoni');
await song('s2', 'Demons', 'Nightcore Gaming');
await song('s3', 'Wild Roses', 'Lori');
await db.setCategories('s1', ['Trap cool']);
await db.createPlaylist('Rosengarten');
});
// Kein db.close(): in Widget-Tests plant drift dabei eine Aufräum-Aufgabe
// ein, die der Testrahmen nicht mehr abarbeitet (siehe BACKLOG.md). Die
// Dienste liegen als `.value` im Baum, werden also nicht von ihm entsorgt.
tearDown(() {
kategorien.dispose();
playlists.dispose();
});
Future<void> pumpe(WidgetTester tester) async {
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
],
child: const MaterialApp(home: Scaffold(body: SearchScreen())),
),
);
await tester.pumpAndSettle();
}
Future<void> tippe(WidgetTester tester, String text) async {
await tester.enterText(find.byType(TextField), text);
// Die Eingabe ist entprellt — ohne dieses Warten passiert nichts.
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
}
Future<void> aufraeumen(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
}
testWidgets('leeres Feld zeigt den Hinweis statt Treffer', (tester) async {
await pumpe(tester);
expect(find.text('Suchbegriff eingeben'), findsOneWidget);
expect(find.text('TITEL 2'), findsNothing);
await aufraeumen(tester);
});
testWidgets('trennt Treffer nach Titel, Künstler und Listen',
(tester) async {
await pumpe(tester);
await tippe(tester, 'rose');
expect(find.text('TITEL 2'), findsOneWidget);
expect(find.text('WIEDERGABELISTEN 1'), findsOneWidget);
expect(find.text('Roses'), findsOneWidget);
expect(find.text('Wild Roses'), findsOneWidget);
expect(find.text('Rosengarten'), findsOneWidget);
// "Demons" passt nicht und darf nicht auftauchen.
expect(find.text('Demons'), findsNothing);
await aufraeumen(tester);
});
testWidgets('findet Künstler als eigenen Abschnitt', (tester) async {
await pumpe(tester);
await tippe(tester, 'nightcore');
expect(find.text('KÜNSTLER 1'), findsOneWidget);
expect(find.text('Nightcore Gaming'), findsWidgets);
await aufraeumen(tester);
});
testWidgets('findet Kategorien als eigenen Abschnitt', (tester) async {
await pumpe(tester);
await tippe(tester, 'trap');
expect(find.text('KATEGORIEN 1'), findsOneWidget);
expect(find.text('Trap cool'), findsWidgets);
await aufraeumen(tester);
});
testWidgets('ohne Treffer erscheint "Nichts gefunden" mit dem Suchbegriff',
(tester) async {
await pumpe(tester);
await tippe(tester, 'jazzfrühstück');
expect(find.text('Nichts gefunden für „jazzfrühstück"'), findsOneWidget);
await aufraeumen(tester);
});
testWidgets('entprellt: direkt nach dem Tippen steht noch nichts da',
(tester) async {
await pumpe(tester);
await tester.enterText(find.byType(TextField), 'rose');
// Weniger als die Entprellzeit — die Treffer dürfen noch nicht stehen.
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('TITEL 2'), findsNothing);
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.text('TITEL 2'), findsOneWidget);
await aufraeumen(tester);
});
testWidgets('abgeschickte Suche steht danach im Verlauf', (tester) async {
await pumpe(tester);
await tippe(tester, 'rose');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
// Feld leeren — dann muss der Verlauf sichtbar werden.
await tester.tap(find.byTooltip('Eingabe löschen'));
await tester.pumpAndSettle();
expect(find.text('ZULETZT GESUCHT'), findsOneWidget);
expect(find.text('rose'), findsOneWidget);
await aufraeumen(tester);
});
}
+28
View File
@@ -67,4 +67,32 @@ void main() {
expect(groupByArtist(const []), isEmpty);
});
});
group('groupByAlbum', () {
test('bündelt Titel desselben Albums', () {
final grouped = groupByAlbum([
_song(id: 'a', title: 'A', album: 'Trailerpark'),
_song(id: 'b', title: 'B', album: 'Trailerpark'),
_song(id: 'c', title: 'C', album: 'Anderes'),
]);
expect(grouped.keys, ['Anderes', 'Trailerpark']);
expect(grouped['Trailerpark'], hasLength(2));
});
test('Titel ohne Album sammeln sich unter "Ohne Album"', () {
final grouped = groupByAlbum([_song(id: 'a', title: 'A')]);
expect(grouped.keys, [ohneAlbum]);
});
test('sortiert ohne Rücksicht auf Groß- und Kleinschreibung', () {
final grouped = groupByAlbum([
_song(id: 'a', title: 'A', album: 'zeta'),
_song(id: 'b', title: 'B', album: 'Alpha'),
]);
expect(grouped.keys, ['Alpha', 'zeta']);
});
});
}
+71
View File
@@ -136,4 +136,75 @@ void main() {
expect(SortMode.playCount.label, 'Wie oft abgespielt');
});
});
group('indexBuchstabe', () {
test('nimmt den ersten Buchstaben, groß geschrieben', () {
expect(indexBuchstabe('anfang'), 'A');
expect(indexBuchstabe('Zenit'), 'Z');
});
test('Umlaute stehen unter ihrem Grundbuchstaben', () {
// Genau wie in der Sortierung: "Ärger" gehört zu A, nicht ans Ende.
expect(indexBuchstabe('Ärger'), 'A');
expect(indexBuchstabe('Über'), 'U');
});
test('Ziffern und Zeichen sammeln sich unter #', () {
expect(indexBuchstabe('9 Lives'), '#');
expect(indexBuchstabe('#Hashtag'), '#');
expect(indexBuchstabe(' '), '#');
});
test('führender Leerraum zählt nicht', () {
expect(indexBuchstabe(' Berg'), 'B');
});
});
group('buchstabenIndex', () {
test('nennt je Buchstabe die erste Position', () {
final sortiert = sortSongs(
[_song('Anfang'), _song('Berg'), _song('Ärger'), _song('9 Lives')],
SortMode.name,
ascending: true,
);
final index = buchstabenIndex(sortiert);
// Sortiert: Anfang, Ärger, Berg, 9 Lives
expect(index['A'], 0);
expect(index['B'], 2);
expect(index['#'], 3);
});
test('ein Buchstabe ohne Titel kommt nicht vor', () {
final index = buchstabenIndex([_song('Berg')]);
expect(index.containsKey('A'), isFalse);
expect(index['B'], 0);
});
test('eine leere Liste ergibt keinen Index', () {
expect(buchstabenIndex(const []), isEmpty);
});
});
group('buchstabenIndex bei ZA', () {
test('nennt auch absteigend die erste Position des Buchstabens', () {
// Der Sprung soll oben im Block landen, nicht unten — deshalb wird der
// Index immer auf der angezeigten Reihenfolge gebaut.
final sortiert = sortSongs(
[_song('Anfang'), _song('Berg'), _song('Bogen'), _song('Zenit')],
SortMode.name,
ascending: false,
);
// Angezeigt: Zenit, Bogen, Berg, Anfang
final index = buchstabenIndex(sortiert);
expect(index['Z'], 0);
expect(index['B'], 1);
expect(index['A'], 3);
expect(index.keys.toList(), ['Z', 'B', 'A']);
});
});
}
+178
View File
@@ -0,0 +1,178 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/library/song_list.dart';
import 'package:melo/library/song_media.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:melo/shared/lauf_balken.dart';
/// Die Verdrahtung zwischen Player und Songzeile — der Teil zwischen der
/// reinen Regel [istAmLaufen] und dem isolierten [LaufMarkierung]. Genau
/// hier saßen die Fehler: fehlender Startwert (die Markierung erschien einen
/// Frame zu spät) und ein Formwechsel des Widget-Baums, der das Herz daneben
/// sein Abo verlieren ließ.
void main() {
late MeloDb db;
late CategoryService kategorien;
late PlaylistService playlists;
late AppSettings einstellungen;
late MeloAudioHandler handler;
/// Die Plugin-Kanäle, die `MeloAudioHandler` beim Bauen anfasst. Ohne diese
/// Antworten werfen sie asynchron und der Testlauf meldet Fremdfehler.
void stelleKanaeleRuhig() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp/melo-test',
);
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
}
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
stelleKanaeleRuhig();
db = MeloDb(NativeDatabase.memory());
kategorien = CategoryService(db);
playlists = PlaylistService(db);
einstellungen = AppSettings();
await einstellungen.init();
handler = MeloAudioHandler(db: db);
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: const Value('Neoni'),
dateAddedMs: 0,
updatedAtMs: 0,
));
});
tearDown(() {
handler.dispose();
kategorien.dispose();
playlists.dispose();
});
Song derSong() => Song(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: 'Neoni',
dateAddedMs: 0,
updatedAtMs: 0,
deleted: false,
playCount: 0,
categoriesEdited: false,
metadataEdited: false,
);
Future<void> pumpe(WidgetTester tester) async {
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),
],
child: MaterialApp(
home: Scaffold(
body: SongZeile(
song: derSong(),
warteschlange: [derSong()],
index: 0,
),
),
),
),
);
}
Future<void> aufraeumen(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
}
testWidgets('ohne Wiedergabe bleibt die Zeile unmarkiert', (tester) async {
await pumpe(tester);
await tester.pump();
expect(find.byType(LaufBalken), findsNothing);
await aufraeumen(tester);
});
testWidgets('läuft der Titel, ist die Zeile markiert', (tester) async {
handler.mediaItem.add(songToMediaItem(derSong()));
await pumpe(tester);
await tester.pump();
expect(find.byType(LaufBalken), findsOneWidget);
await aufraeumen(tester);
});
testWidgets('die Markierung steht schon im ersten Frame', (tester) async {
// Ohne initialData liefert der BehaviorSubject erst im nächsten Frame —
// die Balken ploppten beim Zurückscrollen sichtbar nach.
handler.mediaItem.add(songToMediaItem(derSong()));
await tester.pump();
await pumpe(tester);
// Genau ein Frame, kein zweiter.
expect(find.byType(LaufBalken), findsOneWidget);
await aufraeumen(tester);
});
testWidgets('ein anderer Titel markiert diese Zeile nicht', (tester) async {
handler.mediaItem.add(const MediaItem(
id: 'file:///music/anderes.mp3',
title: 'Anderes',
extras: {'songId': 'song-2'},
));
await pumpe(tester);
await tester.pump();
expect(find.byType(LaufBalken), findsNothing);
await aufraeumen(tester);
});
testWidgets('der Wechsel zu markiert wirft die Zeile nicht weg',
(tester) async {
// Ein Formwechsel des Widget-Baums hätte das Herz daneben sein Abo
// verlieren lassen; es sähe für einen Frame leer aus.
await pumpe(tester);
await tester.pump();
final vorher = tester.element(find.byType(ListTile));
handler.mediaItem.add(songToMediaItem(derSong()));
// Zwei Frames: der Strom liefert erst nach einem Microtask-Durchlauf.
await tester.pump();
await tester.pump();
expect(find.byType(LaufBalken), findsOneWidget);
// Dasselbe Element wie vorher — der Baum wurde nicht verworfen.
expect(tester.element(find.byType(ListTile)), same(vorher));
await aufraeumen(tester);
});
}
@@ -0,0 +1,60 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/song_media.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/services/navidrome_service.dart';
/// Server-Titel wurden bisher an zwei Stellen von Hand zu [MediaItem]s
/// gebaut — beide ohne `artUri`. Auf Sperrbildschirm, in der Liste und im
/// Player blieb deshalb der Noten-Platzhalter stehen, obwohl der Server das
/// Cover unter `getCoverArt.view` bereithält.
void main() {
late NavidromeService nav;
setUp(() {
nav = NavidromeService()
..setCredentials('https://musik.example', 'dustin', 'geheim');
});
const mitCover = SubsonicSong(
id: 'song-42',
titel: 'Roses',
kuenstler: 'Neoni',
album: 'Trailerpark',
dauerSekunden: 221,
coverId: 'al-7',
);
test('das Cover kommt als Server-Adresse mit', () {
final item = subsonicToMediaItem(mitCover, nav);
expect(item.artUri, isNotNull);
expect(item.artUri!.scheme, 'https');
expect(item.artUri!.path, contains('getCoverArt.view'));
expect(item.artUri!.queryParameters['id'], 'al-7');
});
test('ohne coverArt-Tag bleibt die Adresse leer statt zu raten', () {
const ohneCover = SubsonicSong(id: 'song-43', titel: 'Ohne Bild');
expect(subsonicToMediaItem(ohneCover, nav).artUri, isNull);
});
test('Titel, Künstler, Album und Dauer werden übernommen', () {
final item = subsonicToMediaItem(mitCover, nav);
expect(item.title, 'Roses');
expect(item.artist, 'Neoni');
expect(item.album, 'Trailerpark');
expect(item.duration, const Duration(seconds: 221));
});
test('die Server-ID bleibt getrennt von der Abspiel-Adresse', () {
// Sonst fragt die App den Server nach einem Titel namens "https://…"
// — die Wurzel-Ursache von "(0) Source error".
final item = subsonicToMediaItem(mitCover, nav);
expect(navidromeIdOf(item), 'song-42');
expect(item.id, contains('stream.view'));
expect(songIdOf(item), isNull);
});
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/such_verlauf.dart';
/// Die Reihenfolge-Regeln des Verlaufs — ohne SharedPreferences prüfbar.
void main() {
group('verlaufMit', () {
test('legt den neuen Begriff nach vorn', () {
expect(verlaufMit(['neoni', 'rock'], 'trap'), ['trap', 'neoni', 'rock']);
});
test('ein erneut gesuchter Begriff wandert nach vorn statt sich zu doppeln',
() {
expect(verlaufMit(['neoni', 'rock'], 'rock'), ['rock', 'neoni']);
});
test('Groß- und Kleinschreibung gilt als derselbe Begriff', () {
expect(verlaufMit(['Rock'], 'rock'), ['rock']);
});
test('Leerraum am Rand zählt nicht mit', () {
expect(verlaufMit(['rock'], ' rock '), ['rock']);
});
test('deckelt bei acht Einträgen', () {
final voll = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
final neu = verlaufMit(voll, 'i');
expect(neu.length, 8);
expect(neu.first, 'i');
// Der älteste fällt hinten heraus.
expect(neu, isNot(contains('h')));
});
test('ein leerer Begriff ändert nichts', () {
expect(verlaufMit(['rock'], ' '), ['rock']);
});
test('lässt die Ausgangsliste unverändert', () {
final vorher = ['rock'];
verlaufMit(vorher, 'trap');
expect(vorher, ['rock']);
});
});
group('verlaufOhne', () {
test('entfernt genau den gesuchten Begriff', () {
expect(verlaufOhne(['trap', 'neoni', 'rock'], 'neoni'),
['trap', 'rock']);
});
test('Groß- und Kleinschreibung gilt als derselbe Begriff', () {
expect(verlaufOhne(['Rock', 'trap'], 'rock'), ['trap']);
});
test('ein nicht enthaltener Begriff ändert nichts', () {
expect(verlaufOhne(['rock'], 'trap'), ['rock']);
});
test('lässt die Ausgangsliste unverändert', () {
final vorher = ['rock', 'trap'];
verlaufOhne(vorher, 'rock');
expect(vorher, ['rock', 'trap']);
});
});
}
+227
View File
@@ -0,0 +1,227 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/suche.dart';
Song _song(String titel, {String? kuenstler, String? album, String? id}) => Song(
id: id ?? titel.toLowerCase().replaceAll(' ', '-'),
path: '/music/$titel.mp3',
title: titel,
artist: kuenstler,
album: album,
dateAddedMs: 0,
updatedAtMs: 0,
deleted: false,
playCount: 0,
categoriesEdited: false,
metadataEdited: false,
);
Playlist _liste(String name) => Playlist(
id: name.toLowerCase(),
name: name,
createdAtMs: 0,
updatedAtMs: 0,
deleted: false,
);
SuchTreffer _suche(
String query, {
List<Song>? songs,
List<String>? kategorien,
List<Playlist>? playlisten,
}) =>
sucheLokal(
songs: songs ?? const [],
kategorien: kategorien ?? const [],
playlisten: playlisten ?? const [],
query: query,
);
void main() {
group('leere Eingabe', () {
test('findet nichts', () {
final treffer = _suche('', songs: [_song('Roses')]);
expect(treffer.istLeer, isTrue);
});
test('nur Leerzeichen zählen als leer', () {
final treffer = _suche(' ', songs: [_song('Roses')]);
expect(treffer.istLeer, isTrue);
});
});
group('Titel', () {
final songs = [
_song('Roses', kuenstler: 'Neoni', album: 'Trailerpark'),
_song('Demons', kuenstler: 'Nightcore'),
_song('Wild Roses', kuenstler: 'Lori'),
];
test('findet über den Titel', () {
expect(_suche('demons', songs: songs).titel.map((s) => s.title),
['Demons']);
});
test('findet über den Künstler', () {
expect(_suche('neoni', songs: songs).titel.map((s) => s.title),
['Roses']);
});
test('findet über das Album', () {
expect(_suche('trailerpark', songs: songs).titel.map((s) => s.title),
['Roses']);
});
test('ignoriert Groß- und Kleinschreibung', () {
expect(_suche('ROSES', songs: songs).titel.length, 2);
});
test('Treffer am Wortanfang stehen vorn', () {
// "Roses" beginnt mit der Eingabe, "Wild Roses" enthält sie nur.
expect(_suche('roses', songs: songs).titel.map((s) => s.title),
['Roses', 'Wild Roses']);
});
test('ein Titel erscheint nur einmal, auch wenn mehrere Felder passen', () {
final doppelt = [_song('Neoni', kuenstler: 'Neoni', album: 'Neoni')];
expect(_suche('neoni', songs: doppelt).titel.length, 1);
});
});
group('Künstler', () {
final songs = [
_song('A', kuenstler: 'Nightcore Gaming'),
_song('B', kuenstler: 'Nightcore Gaming'),
_song('C', kuenstler: 'Anime Nightcore'),
_song('D', kuenstler: 'Neoni'),
_song('E'),
];
test('nennt jeden Künstler nur einmal', () {
expect(_suche('nightcore', songs: songs).kuenstler,
['Nightcore Gaming', 'Anime Nightcore']);
});
test('Treffer am Wortanfang stehen vorn', () {
expect(_suche('nightcore', songs: songs).kuenstler.first,
'Nightcore Gaming');
});
test('Titel ohne Künstler erzeugen keinen leeren Eintrag', () {
expect(_suche('e', songs: songs).kuenstler, isNot(contains('')));
});
});
group('Kategorien', () {
const kategorien = ['Trap cool', 'Trap neu', 'Nightcore', 'Rock'];
test('findet passende Kategorien', () {
expect(_suche('trap', kategorien: kategorien).kategorien,
['Trap cool', 'Trap neu']);
});
test('findet nichts Unpassendes', () {
expect(_suche('jazz', kategorien: kategorien).kategorien, isEmpty);
});
});
group('Wiedergabelisten', () {
final listen = [_liste('Sport'), _liste('Sonntagmorgen')];
test('findet über den Namen', () {
expect(_suche('sonn', playlisten: listen).playlisten.map((p) => p.name),
['Sonntagmorgen']);
});
});
group('Zusammenspiel', () {
test('zählt alle Abschnitte zusammen', () {
final treffer = _suche(
'rock',
songs: [_song('Rock Anthem', kuenstler: 'Rocker')],
kategorien: ['Rock'],
playlisten: [_liste('Rock-Liste')],
);
expect(treffer.titel.map((s) => s.title), ['Rock Anthem']);
expect(treffer.kuenstler, ['Rocker']);
expect(treffer.kategorien, ['Rock']);
expect(treffer.playlisten.map((p) => p.name), ['Rock-Liste']);
expect(treffer.istLeer, isFalse);
});
test('ohne Treffer ist das Ergebnis leer, nicht null', () {
final treffer = _suche('gibtesnicht', songs: [_song('Roses')]);
expect(treffer.istLeer, isTrue);
expect(treffer.titel, isEmpty);
expect(treffer.kuenstler, isEmpty);
});
});
group('kuenstlerNachSchluessel', () {
test('fasst Schreibweisen zusammen, wie es die Suche tut', () {
// Genau der Bruch, der "0 Titel" und einen leeren Bildschirm ergab:
// die Suche nannte einen Künstler, die Zuordnung fand ihn nicht.
final gebuendelt = kuenstlerNachSchluessel([
_song('A', kuenstler: 'Neoni'),
_song('B', kuenstler: 'NEONI'),
_song('C', kuenstler: 'Neoni '),
]);
expect(gebuendelt.keys, ['neoni']);
expect(gebuendelt['neoni']!.length, 3);
});
test('der Treffer der Suche findet seine Titel wieder', () {
final songs = [_song('A', kuenstler: 'Neoni '), _song('B', kuenstler: 'NEONI')];
final treffer = sucheLokal(
songs: songs, kategorien: const [], playlisten: const [], query: 'neoni');
final gebuendelt = kuenstlerNachSchluessel(songs);
final name = treffer.kuenstler.single;
expect(gebuendelt[suchSchluessel(name)]!.length, 2);
});
test('Titel ohne Künstler kommen nicht vor', () {
expect(kuenstlerNachSchluessel([_song('A'), _song('B', kuenstler: ' ')]),
isEmpty);
});
});
group('kategorienNachSchluessel', () {
test('fasst Schreibweisen zusammen', () {
final a = _song('A', id: 'a');
final b = _song('B', id: 'b');
final gebuendelt = kategorienNachSchluessel(
[a, b],
(s) => s.id == 'a' ? ['Rock'] : ['rock'],
);
expect(gebuendelt.keys, ['rock']);
expect(gebuendelt['rock']!.length, 2);
});
test('ein Titel steht unter jeder seiner Kategorien', () {
final gebuendelt = kategorienNachSchluessel(
[_song('A')],
(_) => ['Rock', 'Chill'],
);
expect(gebuendelt.keys.toSet(), {'rock', 'chill'});
});
test('leere Kategorienamen werden übergangen', () {
expect(kategorienNachSchluessel([_song('A')], (_) => [' ']), isEmpty);
});
});
group('suchSchluessel', () {
test('trimmt und senkt die Schreibweise', () {
expect(suchSchluessel(' NEONI '), 'neoni');
});
});
}
-9
View File
@@ -32,15 +32,6 @@ void main() {
expect(songs.first.title, 'Neu');
});
test('searchSongs findet über Titel und Künstler', () async {
await db.upsertSongs([
song('1', '/a.mp3', 'Nachtpuls', artist: 'Rotklang'),
song('2', '/b.mp3', 'Taglicht', artist: 'Beatzwei'),
]);
expect((await db.searchSongs('nacht').first).length, 1);
expect((await db.searchSongs('rotklang').first).length, 1);
expect((await db.searchSongs('xyz').first).length, 0);
});
test('markMissing setzt Tombstone für nicht aktualisierte Dateien', () async {
final now1 = 100;
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/player/auto_browse.dart';
/// Im Auto und beim Sprachassistenten blättert man durch die Bibliothek,
/// statt die App zu bedienen. Jeder Knoten braucht dafür eine Adresse, die
/// hin und zurück eindeutig ist.
void main() {
group('knotenId und knotenAus', () {
test('eine Ordner-Adresse ohne Namen kommt unverändert zurück', () {
final id = knotenId(AutoArt.favoriten);
final knoten = knotenAus(id);
expect(knoten!.art, AutoArt.favoriten);
expect(knoten.name, isNull);
});
test('ein Name übersteht den Weg hin und zurück', () {
final id = knotenId(AutoArt.kategorie, 'Trap cool');
final knoten = knotenAus(id);
expect(knoten!.art, AutoArt.kategorie);
expect(knoten.name, 'Trap cool');
});
test('Doppelpunkte im Namen zerlegen die Adresse nicht', () {
// "Intro: Der Anfang" ist ein völlig normaler Albumtitel — und der
// Doppelpunkt ist zugleich unser Trennzeichen.
final knoten = knotenAus(knotenId(AutoArt.kategorie, 'Intro: Anfang'));
expect(knoten!.name, 'Intro: Anfang');
});
test('Schrägstriche und Umlaute überstehen es ebenfalls', () {
const name = 'AC/DC — Küchenlieder';
expect(knotenAus(knotenId(AutoArt.kuenstler, name))!.name, name);
});
test('ein Titel trägt seine Song-UUID', () {
final knoten = knotenAus(knotenId(AutoArt.titel, 'uuid-1'));
expect(knoten!.art, AutoArt.titel);
expect(knoten.name, 'uuid-1');
});
test('eine fremde Adresse ergibt nichts', () {
expect(knotenAus('irgendwas'), isNull);
expect(knotenAus(''), isNull);
expect(knotenAus('melo:gibtesnicht'), isNull);
});
});
group('wurzelArten', () {
test('bietet die vier Wege an, die auch die App hat', () {
expect(wurzelArten, [
AutoArt.favoriten,
AutoArt.zuletzt,
AutoArt.kategorien,
AutoArt.kuenstlerListe,
]);
});
test('jede Wurzel hat einen Namen für die Anzeige', () {
for (final art in wurzelArten) {
expect(art.anzeige, isNotEmpty);
}
});
test('die Wurzeln sind Ordner, keine Titel', () {
// Ein Ordner darf nicht abspielbar sein, sonst versucht das Auto ihn
// als Lied zu starten.
for (final art in wurzelArten) {
expect(art == AutoArt.titel, isFalse);
}
});
});
}
+98
View File
@@ -0,0 +1,98 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/player/cover_farbe.dart';
import 'package:melo/shared/theme.dart';
/// Baut ein RGBA-Bild aus Farben — je Farbe [wiederholungen] Bildpunkte.
Uint8List _bild(List<(Color, int)> teile) {
final bytes = <int>[];
for (final (farbe, anzahl) in teile) {
for (var i = 0; i < anzahl; i++) {
bytes.addAll([
(farbe.r * 255).round(),
(farbe.g * 255).round(),
(farbe.b * 255).round(),
(farbe.a * 255).round(),
]);
}
}
return Uint8List.fromList(bytes);
}
/// Der Player war für jeden Titel gleich schwarz. Diese Regel holt die
/// prägende Farbe aus dem Coverbild.
void main() {
group('dominanteFarbe', () {
test('ein einfarbiges Bild ergibt seine Farbe', () {
const rot = Color(0xFFD32F2F);
final farbe = dominanteFarbe(_bild([(rot, 100)]));
// Grob gerundet — die Farben werden in Töpfe einsortiert.
expect((farbe.r * 255).round(), closeTo(211, 16));
expect((farbe.g * 255).round(), closeTo(47, 16));
expect((farbe.b * 255).round(), closeTo(47, 16));
});
test('eine kräftige Farbe schlägt viel Grau', () {
// Cover sind oft überwiegend dunkel oder grau. Die häufigste Farbe wäre
// dann ein Grauton, und der Player sähe für jeden Titel gleich aus.
final farbe = dominanteFarbe(_bild([
(const Color(0xFF808080), 900),
(const Color(0xFF00A0FF), 100),
]));
expect(farbe.b, greaterThan(farbe.r));
});
test('ein rein schwarzes Bild fällt auf die Markenfarbe zurück', () {
expect(dominanteFarbe(_bild([(const Color(0xFF000000), 100)])),
MeloTheme.red);
});
test('ein rein weißes Bild fällt ebenfalls zurück', () {
expect(dominanteFarbe(_bild([(const Color(0xFFFFFFFF), 100)])),
MeloTheme.red);
});
test('durchsichtige Bildpunkte zählen nicht mit', () {
final farbe = dominanteFarbe(_bild([
(const Color(0x0000FF00), 900),
(const Color(0xFFD32F2F), 100),
]));
expect(farbe.r, greaterThan(farbe.g));
});
test('ein leeres Bild fällt auf die Markenfarbe zurück', () {
expect(dominanteFarbe(Uint8List(0)), MeloTheme.red);
});
test('unvollständige Bildpunkte am Ende stören nicht', () {
// Abgeschnittene Daten dürfen keinen Bereichsfehler auslösen.
final bytes = _bild([(const Color(0xFFD32F2F), 10)]).toList()
..addAll([12, 34]);
expect(() => dominanteFarbe(Uint8List.fromList(bytes)), returnsNormally);
});
});
group('lesbarAuf', () {
test('dunkle Farben bleiben dunkel genug für weiße Schrift', () {
final farbe = lesbarAuf(const Color(0xFFFFEB3B));
// Ein knalliges Gelb muss abgedunkelt werden, sonst ist weiße Schrift
// darauf nicht mehr lesbar.
expect(farbe.computeLuminance(), lessThan(0.35));
});
test('eine bereits dunkle Farbe bleibt weitgehend erhalten', () {
const dunkel = Color(0xFF1A237E);
expect(lesbarAuf(dunkel).computeLuminance(),
closeTo(dunkel.computeLuminance(), 0.1));
});
});
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/song_media.dart';
import 'package:melo/player/audio_handler.dart';
Song _song(String id) => Song(
id: id,
path: '/music/$id.mp3',
title: 'Titel $id',
dateAddedMs: 0,
updatedAtMs: 0,
deleted: false,
playCount: 0,
categoriesEdited: false,
metadataEdited: false,
);
/// Ohne diese Regel zeigten die Listen nicht, wo man gerade ist: man scrollte
/// durch die Bibliothek und musste raten, welcher Titel läuft.
void main() {
group('istAmLaufen', () {
test('erkennt den laufenden Titel an seiner UUID', () {
final laufend = songToMediaItem(_song('uuid-1'));
expect(istAmLaufen(laufend, 'uuid-1'), isTrue);
});
test('ein anderer Titel läuft nicht', () {
final laufend = songToMediaItem(_song('uuid-1'));
expect(istAmLaufen(laufend, 'uuid-2'), isFalse);
});
test('ohne Wiedergabe läuft nichts', () {
expect(istAmLaufen(null, 'uuid-1'), isFalse);
});
test('ein Server-Titel markiert keine Zeile der Bibliothek', () {
// Er trägt nur eine navidromeId, keine UUID — ohne die Prüfung auf den
// leeren String würde er jede Zeile ohne UUID markieren.
const serverTitel = MediaItem(
id: 'https://musik.example/rest/stream.view?id=abc',
title: 'Serverlied',
extras: {'navidromeId': 'abc'},
);
expect(istAmLaufen(serverTitel, ''), isFalse);
expect(istAmLaufen(serverTitel, 'uuid-1'), isFalse);
});
test('eine Zeile ohne UUID wird nie markiert', () {
final laufend = songToMediaItem(_song('uuid-1'));
expect(istAmLaufen(laufend, ''), isFalse);
});
});
}

Some files were not shown because too many files have changed in this diff Show More