Compare commits
70
Commits
+10
@@ -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
|
||||
> (K1–K5) sowie G1–G10. 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
@@ -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
|
||||
|
||||
+1242
File diff suppressed because it is too large
Load Diff
+111
@@ -0,0 +1,111 @@
|
||||
# Login-Analyse — Stand 2026-08-30 (Melo pausiert als Nebenprojekt)
|
||||
|
||||
Dieses Dokument sichert den Stand der Login-Bug-Analyse, bevor die
|
||||
Melo-Session geschlossen wird. Melo ist pausiert — das hier ist der
|
||||
Übergabe-Stand für den nächsten Anlauf.
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
Auftrag: Auto-Anmeldung + manuelle Baka-Anmeldung schlugen in der App fehl,
|
||||
obwohl die Server-Seite (baka_auth) laut Login-API-Tests für `baka` und
|
||||
`tinker` einwandfrei lief. Vorgabe: Server ist nicht das Problem, den Bug in
|
||||
der APP finden + fixen.
|
||||
|
||||
## Ergebnis: Der Bug war NICHT in der App
|
||||
|
||||
Vollständige Prüfung des App-Codes ergab keinen Fehler:
|
||||
|
||||
- `lib/services/baka_auth.dart`, `lib/services/gast_zugang.dart`,
|
||||
`lib/downloads/downloads_screen.dart`, `lib/downloads/youtube_search_screen.dart`
|
||||
gelesen und nachvollzogen — Logik korrekt.
|
||||
- Beide **früher bereits gefundenen** Login-Bugs sind längst im `main`-Branch
|
||||
gemergt und aktuell vorhanden:
|
||||
- Race Condition beim Auto-Login (`_laufenderAutoLogin` in `baka_auth.dart`)
|
||||
— Commit `609ca37edeb`.
|
||||
- Manuelle Anmeldung schreibt das neue Passwort dauerhaft in
|
||||
`NavidromeService` zurück (`_AnmeldeDialogState._anmelden()`) —
|
||||
Commit `196303145a3`.
|
||||
- 39 relevante Unit-/Widget-Tests grün (`baka_auth_test.dart`,
|
||||
`gast_zugang_test.dart`, `online_screen_test.dart`).
|
||||
- Echter Server-Endpoint `https://baka-net.de/auth/login` direkt per `curl`
|
||||
getestet (von diesem Server aus, der zugleich `baka-net.de` selbst ist) —
|
||||
Response-Format entspricht exakt dem, was der App-Code erwartet.
|
||||
- DNS/TLS/Netzwerk/AndroidManifest: kein Problem gefunden.
|
||||
|
||||
## Tatsächlicher Root Cause: Server-Bug (behoben)
|
||||
|
||||
`/home/dustin/scripts/baka_auth.py` (**außerhalb des App-Repos**, kein Git,
|
||||
Produktions-Auth-Server für baka-net.de/auth):
|
||||
|
||||
`handle_login()` verglich den Benutzernamen **case-sensitiv**
|
||||
(`WHERE username=?`), während jede andere Stelle im selben System
|
||||
(`handle_whoami`, `nd_sync` und `baka_auth_sync` in `sso_sync.py`) bewusst
|
||||
**case-insensitiv** vergleicht. Die App sendet die Schreibweise aus den
|
||||
gespeicherten Navidrome-Zugangsdaten — weicht die auch nur in
|
||||
Groß-/Kleinschreibung von der in `baka_auth.db` gespeicherten Schreibweise ab
|
||||
(z. B. durch den SSO-Sync von Authentik), meldet der Server "Falscher
|
||||
Benutzer", obwohl Konto und Passwort stimmen. Das erklärt exakt das Muster:
|
||||
Server-Tests mit exakt passender Schreibweise liefen grün, echte
|
||||
App-Logins (Auto- **und** manuelle Anmeldung) schlugen fehl.
|
||||
|
||||
Dieser Bug war bereits am 29.08. von einer früheren Session an Hermes
|
||||
gemeldet worden (siehe Session `618ae854-...`, Deep-Investigation-Agent),
|
||||
aber nie umgesetzt — die Datei war bis zum Fix unverändert seit dem 20.08.
|
||||
|
||||
**Fix (2026-08-30, mit Dustins Freigabe):**
|
||||
- `handle_login()` in `baka_auth.py`: Abfrage jetzt
|
||||
`WHERE lower(username)=lower(?)`, konsistent mit dem Rest des Systems.
|
||||
Kanonischer Username aus der DB-Zeile wird danach für Session/`last_login`
|
||||
weiterverwendet (Indizes im Ergebnis-Tuple entsprechend angepasst).
|
||||
- Backup vor der Änderung: `/home/dustin/scripts/baka_auth.py.bak-20260830-221410`.
|
||||
- `baka-auth.service` neu gestartet (`sudo systemctl restart baka-auth.service`).
|
||||
- Health-Check (`/auth/health`) und Login-Endpoint (mit unbekanntem User,
|
||||
liefert weiterhin korrekt "Falscher Benutzer") danach verifiziert.
|
||||
|
||||
## Offener Punkt — als Nächstes prüfen
|
||||
|
||||
**Die tatsächliche Wirkung des Fixes wurde noch NICHT mit echten
|
||||
Zugangsdaten auf einem echten Gerät bestätigt.** Direkter Zugriff auf
|
||||
`baka_auth.db` (sqlite3) war in dieser Session durch den
|
||||
Berechtigungs-Classifier blockiert — die genaue gespeicherte Schreibweise
|
||||
für `baka`/`tinker` konnte daher nicht verifiziert werden. Der Fix selbst
|
||||
ist unabhängig davon korrekt (er wendet nur die im System bereits etablierte
|
||||
Konvention konsequent auch auf `handle_login` an), aber der praktische Beweis
|
||||
fehlt noch.
|
||||
|
||||
**Nächster Schritt:** Dustin, Baka oder Tinker sollten die App (Auto-Login
|
||||
beim Start, danach zur Sicherheit auch die manuelle Anmeldung im
|
||||
"YouTube"-Tab) einmal real testen. Falls es weiterhin fehlschlägt:
|
||||
1. `journalctl -u baka-auth.service -n 50` auf die konkrete Fehlermeldung
|
||||
prüfen.
|
||||
2. Prüfen, ob eine Login-Sperre aktiv ist (5 Fehlversuche → 15 Min. Sperre,
|
||||
`_login_erlaubt`/`_login_fehler` in `baka_auth.py`) — die frühere
|
||||
Investigation vermutete, dass wiederholte Auto-Login-Versuche mit falscher
|
||||
Schreibweise genau das ausgelöst haben könnten.
|
||||
3. Falls weiterhin ein Mismatch vermutet wird: `sso_sync.py` prüfen, ob
|
||||
`baka_auth_sync()` beim Anlegen eines neuen Users die vom Aufrufer
|
||||
übergebene Schreibweise 1:1 übernimmt (`INSERT ... VALUES(?,...)` mit dem
|
||||
Original-`username`-Parameter) — das ist die einzige Stelle, an der die
|
||||
in `baka_auth.db` gespeicherte Schreibweise ursprünglich entsteht.
|
||||
|
||||
## Nebenbefund — geringes Risiko, nicht angefasst
|
||||
|
||||
Der Rate-Limit-Schlüssel für Login-Sperren (`_login_erlaubt`/`_login_fehler`
|
||||
in `baka_auth.py`) verwendet weiterhin die **rohe, ungenormte** Eingabe
|
||||
(`body.get('username', '?')`), nicht den kanonischen Namen. Bei
|
||||
unterschiedlicher Groß-/Kleinschreibung über mehrere Login-Versuche hinweg
|
||||
zählt das Rate-Limit dadurch potenziell inkonsistent (mehrere Zähler pro
|
||||
echtem Nutzer statt einem). Kein Sicherheitsproblem, nur Ungenauigkeit —
|
||||
absichtlich nicht mitgefixt, um die Änderung minimal zu halten.
|
||||
|
||||
## Für die nächste Session
|
||||
|
||||
- Dieser Fix betrifft **kein App-Repo** — es gibt keinen App-Commit dazu,
|
||||
nur diese Datei hier als Doku. Die eigentliche Änderung liegt in
|
||||
`/home/dustin/scripts/baka_auth.py` auf dem Server (kein Git-Tracking).
|
||||
- Hermes/Server-Seite sollte informiert werden, dass der am 29.08. gemeldete
|
||||
Bug jetzt behoben ist (falls das nicht schon anderweitig passiert ist).
|
||||
- Volltext der ursprünglichen Root-Cause-Kette (Race Condition →
|
||||
Passwort-Rückschreibung → getrennte Passwort-Speicher
|
||||
Navidrome/baka_auth.db → Case-Sensitivity) steht im Transkript der Session
|
||||
`618ae854-0a01-4321-af16-a3fef7ecbc7e` (`~/.claude/projects/-home-dustin-mello-dev/`).
|
||||
@@ -0,0 +1,88 @@
|
||||
# Sync-Ausbau — Kontext-Sicherung (2026-08-27)
|
||||
|
||||
Diese Datei ist eine Übergabe für eine neue Session. Sie ersetzt nicht die
|
||||
Spec/den Plan, sondern sagt: wo stehen wir, was ist als Nächstes dran.
|
||||
|
||||
**Wenn diese Datei erledigt ist (Implementierung fertig oder Plan überholt):
|
||||
löschen, nicht liegen lassen — sie ist kein Dauerdokument.**
|
||||
|
||||
## Die zwei maßgeblichen Dokumente
|
||||
|
||||
1. **Spec:** `docs/superpowers/specs/2026-08-27-sync-ausbau-design.md`
|
||||
Status: **Freigegeben, umsetzungsbereit.** Durch ein 5-Reviewer-Panel
|
||||
(agent-review-panel) geprüft, nachgeschärft, gegengelesen.
|
||||
2. **Implementierungsplan:** `docs/superpowers/plans/2026-08-27-sync-ausbau.md`
|
||||
13 Tasks nach TDD (echter Code in jedem Schritt, kein Platzhalter),
|
||||
geprüft und korrigiert.
|
||||
|
||||
Beide sind committed und gepusht auf Branch `fix/p0-vollwertigkeit`
|
||||
(bis Commit `1940e2b7909`).
|
||||
|
||||
## Was das Feature ist (kurz)
|
||||
|
||||
Sync-Ausbau der Melo-App: Favoriten-Merge-Fix (Datenverlust-Bug),
|
||||
gezielter Song-Upload im Auswahl-Modus, Einzel-Song-Offline,
|
||||
Playlist-Sicherung (einseitig), „Was ist neu"-Bericht. SSE, persistente
|
||||
Notification und beidseitiger Playlist-Merge sind bewusst spätere Stufen,
|
||||
nicht Teil dieser Runde.
|
||||
|
||||
## Aktueller Stand — was schon passiert ist
|
||||
|
||||
1. Bestandsaufnahme (6 parallele Leser über App/Server/Navidrome/Cross-Platform/Melo-v2).
|
||||
2. Brainstorming + Spec geschrieben, mit Dustin abgestimmt.
|
||||
3. **agent-review-panel** (5 Reviewer, 2 Debattenrunden, Audit, Verifikation,
|
||||
Richterurteil): Score 6/10, Verdikt „nachschärfen dann freigeben". Bericht:
|
||||
`docs/reviews/2026-08-27-sync-ausbau/review_panel_report.md`.
|
||||
4. Spec nach den 17 Aktionspunkten des Urteils überarbeitet, gegengelesen,
|
||||
freigegeben.
|
||||
5. Implementierungsplan geschrieben, geprüft (fand 3 echte
|
||||
Server-Vertragsfehler in Task 11 — korrigiert), Korrekturrunde committed.
|
||||
6. **Der einzige echte Blocker ist behoben:** Ein irreversibler Löschpfad in
|
||||
`melo_cloud.py` (verschwindet eine lokale Datei, wurde sie serverseitig
|
||||
endgültig gelöscht, erneutes Hochladen reparierte es nicht). Hermes hat
|
||||
den Fix eingebaut (Datei-Wiederherstellung im Dedup-Zweig von `upload()`),
|
||||
ich habe ihn end-to-end getestet (eigene Testdatei, Fake-Nutzer, danach
|
||||
restlos aufgeräumt — Registry vor/nach Test bei 325 Titeln): Upload →
|
||||
Löschung (Bug reproduziert, Download 404) → erneuter Upload → **beide
|
||||
Dateien bitgenau wiederhergestellt.** Spec und Plan sind entsprechend
|
||||
aktualisiert (§ERLEDIGT in der Spec, ENTBLOCKT-Marker im Plan).
|
||||
|
||||
## Nächster Schritt
|
||||
|
||||
**Implementierungsplan, Task 1: „Reine Merge-Funktionen (`sync_merge.dart`)“**
|
||||
(Zeile ~104 im Plan). Tasks 1–9 sind vollständig entblockt und können sofort
|
||||
starten, in der im Plan festgelegten Reihenfolge (Favoriten-Fix zuerst).
|
||||
|
||||
Empfohlener Einstieg für die neue Session: die Skill
|
||||
`superpowers:subagent-driven-development` (Pflicht-Sub-Skill laut Plankopf)
|
||||
für die task-weise Umsetzung nutzen, wie schon beim vorherigen Feature
|
||||
(YouTube-Gast-Zugang) erfolgreich gemacht.
|
||||
|
||||
## Was noch offen ist (kein Blocker, aber zu klären)
|
||||
|
||||
- **Tasks 10–12 (Playlist-Sicherung, Feature 2):** bauen oder streichen?
|
||||
Heute 0 Playlisten auf dem Server. Plan empfiehlt: vertagen, bis eine
|
||||
echte Playlist existiert. Steht im Plan unter „Offene Punkte, die vor
|
||||
bzw. während der Umsetzung an Dustin gehen".
|
||||
- **Server-Hygiene (an Hermes, eilt nicht):** `_link_user()` im Dedup-Zweig
|
||||
von `upload()` legt einen dritten Hardlink unter `users/<user>/<sid>.mp3`
|
||||
an, den `_entferne_datei_wenn_verwaist()` beim Löschen nicht mit abräumt.
|
||||
Bisher folgenlos, aber seit dem Wiederherstellungs-Fix hinterlässt jeder
|
||||
Dedup-Upload einen verwaisten Hardlink.
|
||||
- **App-Seite, bewusst unangetastet:** `markMissing` tombstoned großzügig,
|
||||
die Lösch-Bremse greift erst ab 109 von 325 Titeln. Der Schaden ist jetzt
|
||||
reparabel (siehe oben), also kein Blocker mehr — ob die App trotzdem
|
||||
vorsichtiger werden soll, ist eine eigene, spätere Entscheidung.
|
||||
|
||||
## Regeln, die für die neue Session gelten (aus Erfahrung dieser Session)
|
||||
|
||||
- Worktree-Lock beachten: `.claude-worktree-lock` im Repo-Root prüfen/setzen/
|
||||
entfernen (siehe `melo-app-workflow`-Skill).
|
||||
- `flutter test` im Hintergrund laufen lassen (600+ Tests, mehrere Minuten,
|
||||
kein kurzer Foreground-Timeout).
|
||||
- Vor jedem Commit: Qualitäts-Gate (analyze + test) läuft automatisch als
|
||||
Pre-Commit-Hook — das ist schon eingerichtet, nichts zu tun.
|
||||
- CHANGELOG.md nach jeder Aufgabe aktualisieren (Projektregel).
|
||||
- Bei fertigem Arbeitspaket: melden und stoppen, nicht autonom weitermachen
|
||||
(Dustin startet dann bewusst eine frische Session — siehe
|
||||
Memory `feedback_melden-und-stoppen`).
|
||||
@@ -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. |
|
||||
| A11–A16 | 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 (0–8192 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 0–8192 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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
# 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 |
|
||||
| Limit für Cloud-Accounts | **Nachtrag 2026-08-29:** 100 Downloads/Kalendertag je Nutzer (Dustin/Baka/Tinker je einzeln gezählt) — ersetzt das ursprüngliche „kein Limit". Grund: dieselbe Absicherung wie beim Gast-Limit, nur großzügiger, da Cloud-Accounts vertraute Nutzer mit Cookie-Zugriff sind. |
|
||||
|
||||
## Architektur
|
||||
|
||||
Drei Zugangsklassen beim Proxy:
|
||||
|
||||
1. **Cloud-Account** (`Authorization: Bearer <JWT>`, wie heute) — **100
|
||||
Downloads/Kalendertag je Nutzer** (Nachtrag 2026-08-29, ersetzt
|
||||
„unbegrenzt"), Cookies automatisch, Suche unbegrenzt (wie bei Gästen).
|
||||
2. **Gast** (`X-Guest-Token: <token>`, neu) — 5 Downloads/Kalendertag,
|
||||
Suche unbegrenzt, keine Cookies.
|
||||
3. **Kein Zugang** — weder Bearer noch gültiger Gast-Token → 401 wie heute.
|
||||
|
||||
## Nachtrag 2026-08-29: 100/Tag-Limit auch für Cloud-Accounts
|
||||
|
||||
Dustin hat beim erneuten Durchsprechen der Architektur (nach den
|
||||
Login-Race-Fixes) bestätigt, dass auch Cloud-Accounts (Dustin, Baka,
|
||||
Tinker) ein Tages-Limit bekommen sollen — 100 statt der ursprünglich
|
||||
geplanten 5, aber nicht mehr unbegrenzt. Grund: dieselbe
|
||||
Missbrauchs-/Kosten-Absicherung wie beim Gast-Limit (YouTube-Rate-Limits,
|
||||
Serverlast), nur mit großzügigerem Kontingent, weil Cloud-Accounts
|
||||
vertraute, bekannte Nutzer sind und zusätzlich Cookie-Zugriff haben.
|
||||
|
||||
**Übernommene Entscheidungen des Gast-Limits (analog, keine neue
|
||||
Diskussion nötig):** Reset zum Kalendertag (Server-Zeit), Suche zählt
|
||||
nicht, nur `POST /api/yt-dl` zählt, Klartext-429-Fehlermeldung ohne
|
||||
automatischen Retry.
|
||||
|
||||
**Unterschied zum Gast-Limit:** Cloud-Accounts zählen **pro Nutzername**
|
||||
(aus dem verifizierten JWT), nicht pro Gerät/Token — ein Nutzer, der die
|
||||
App auf zwei Geräten installiert hat, teilt sich also ein gemeinsames
|
||||
Kontingent (folgerichtig, da derselbe Baka-Account).
|
||||
|
||||
### Server-seitige Änderungen (zusätzlich zu den bestehenden Abschnitten oben — Hermes/`claude-server`-Worktree)
|
||||
|
||||
- Neue Tabelle (oder Erweiterung von `guest_quota`, falls strukturell
|
||||
identisch — Entscheidung liegt bei der Server-Session) analog zu
|
||||
`guest_quota`, aber mit `username` statt `token` als Schlüssel:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS cloud_quota (
|
||||
username TEXT PRIMARY KEY,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
count_date TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
- `POST /api/yt-dl` bei Cloud-Zugriff (gültiger Bearer-JWT): dieselbe
|
||||
Tageswechsel-Reset-Logik wie bei Gästen, aber Schlüssel = Nutzername aus
|
||||
dem JWT. Bei `count >= 100` → `429` mit
|
||||
`{"error": "Tages-Limit erreicht (100/Tag) — ab morgen wieder verfügbar", "cooldown": <Sekunden bis Mitternacht>}`.
|
||||
Erfolgsantwort bekommt zusätzlich `"cloud_remaining": <int>` (nur bei
|
||||
Cloud-Zugriff, analog zu `guest_remaining` bei Gästen — beide Felder
|
||||
schließen sich gegenseitig aus, nie beide gleichzeitig gesetzt).
|
||||
- `GET /api/search` bleibt für Cloud-Accounts unverändert unbegrenzt (wie
|
||||
für Gäste).
|
||||
|
||||
### App-seitige Änderungen (diese Seite implementiere ich)
|
||||
|
||||
- `BakaAuth` (`lib/services/baka_auth.dart`) bekommt — analog zu
|
||||
`GastZugang.verbleibend`/`.merkeVerbleibend(n)` — ein neues Feld
|
||||
`int? verbleibend` (Getter) und `void merkeVerbleibend(int n)`, das den
|
||||
zuletzt vom Server gemeldeten Kontingent-Stand hält (nur fürs Anzeigen,
|
||||
`null` vor dem ersten Download in dieser Session).
|
||||
- `YtDownloadService.herunterladen()` (`lib/services/yt_download_service.dart`):
|
||||
liest nach einem erfolgreichen `POST /api/yt-dl` zusätzlich
|
||||
`daten['cloud_remaining']` aus, und ruft bei `!gastAktiv` (also
|
||||
Cloud-Zugriff) `auth.merkeVerbleibend(cloudRest)` auf — analog zur
|
||||
bestehenden `gast!.merkeVerbleibend(gastRest)`-Zeile. Ein `429` läuft
|
||||
bereits durch den bestehenden generischen `_fehlerText(antwort)`-Pfad
|
||||
(kein neuer Fehlerfall nötig, der zeigt jeden Server-`error`-Text aus dem
|
||||
200-fremden Statuscode an — funktioniert für 429 genauso wie für 401
|
||||
außerhalb des Spezialfalls).
|
||||
- UI (`_YouTubeBereich` in `lib/downloads/downloads_screen.dart`,
|
||||
`YoutubeSearchScreen` in `lib/downloads/youtube_search_screen.dart`):
|
||||
zeigt analog zum bestehenden Gast-Text („Als Gast unterwegs — noch N von
|
||||
5 heute") jetzt auch für angemeldete Cloud-Accounts einen Text „Noch N
|
||||
von 100 heute", sobald `auth.verbleibend != null` — platziert an
|
||||
vergleichbarer Stelle wie der Gast-Text, aber nur sichtbar wenn
|
||||
`auth.istAngemeldet` (nicht für Gäste, die haben ihren eigenen Text).
|
||||
|
||||
### Tests (Nachtrag)
|
||||
|
||||
- **App:** `BakaAuth.merkeVerbleibend`, `YtDownloadService` liest
|
||||
`cloud_remaining` bei Cloud-Zugriff (nicht bei Gast-Zugriff — die beiden
|
||||
Felder dürfen sich nicht vermischen), Widget-Test für den neuen
|
||||
„Noch N von 100 heute"-Text in beiden Bildschirmen.
|
||||
- **Server** (Hermes): analog zu den bestehenden Gast-Tests, plus ein Test,
|
||||
der sicherstellt, dass Gast- und Cloud-Kontingente unabhängig
|
||||
voneinander gezählt werden (ein Cloud-Login verbraucht kein
|
||||
Gast-Kontingent und umgekehrt — sie sind ohnehin durch unterschiedliche
|
||||
Auth-Wege getrennt, aber das schadet nicht, explizit zu testen).
|
||||
|
||||
## Server-seitige Komponenten
|
||||
|
||||
**Betrifft `/home/dustin/scripts/yt_proxy.py` und ggf. `auth_common.py` —
|
||||
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
|
||||
A1–A17 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 0–8192 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 4–6**. 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.
|
||||
@@ -0,0 +1,224 @@
|
||||
# Vollbild-Player als persistentes Overlay mit Live-Blur-Übergang
|
||||
|
||||
Status: Approved (Dustin, 2026-08-29) — bereit für Implementierungsplan.
|
||||
|
||||
## Kontext
|
||||
|
||||
Punkt 2 aus dem Animations-Bericht zu Xiaomis HyperOS-Design-Sprache
|
||||
(fließende, physikalisch wirkende Bewegung statt starrer, linearer
|
||||
Animation): Der Übergang vom Mini-Player zum Vollbild-Player
|
||||
(`NowPlayingScreen`) soll sich beim Öffnen/Schließen per Wischgeste
|
||||
reaktiv anfühlen — der Hintergrund soll live mit der Zugdistanz des
|
||||
Fingers verschwimmen (Blur), nicht erst nach Abschluss einer festen
|
||||
Animation.
|
||||
|
||||
Heute (`lib/player/mini_player.dart`, `lib/player/now_playing_screen.dart`,
|
||||
`lib/player/wischen.dart`) ist die Wischgeste rein **entscheidungsbasiert**:
|
||||
Der Mini-Player folgt beim Ziehen sichtbar dem Finger (`_versatz` +
|
||||
`AnimatedContainer`/`Matrix4.translationValues`), aber die eigentliche
|
||||
Navigation passiert erst nach `onVerticalDragEnd` als normaler
|
||||
`Navigator.push(MaterialPageRoute(builder: (_) => const NowPlayingScreen()))`.
|
||||
Während des Ziehens existiert `NowPlayingScreen` im Widget-Baum noch gar
|
||||
nicht — es gibt keine live-Verbindung zwischen Zugdistanz und einer
|
||||
Bildschirm-Transition. Dieselbe Route wird an drei weiteren, gestenlosen
|
||||
Stellen per einfachem Tap gepusht: `lib/library/song_list.dart`,
|
||||
`lib/playlists/playlist_detail_screen.dart`,
|
||||
`lib/downloads/server_titel_screen.dart`.
|
||||
|
||||
Zwei Architektur-Ansätze wurden im Brainstorming verglichen (interaktive
|
||||
`PageRoute` mit geteiltem Fortschritts-Notifier vs. persistentes Overlay).
|
||||
Dustin hat sich für das **persistente Overlay** entschieden (Ansatz 2) —
|
||||
näher an der Bauweise nativer Musik-Apps (Spotify/Apple Music), auch wenn
|
||||
der Eingriff größer ist als bei der interaktiven-Route-Variante.
|
||||
|
||||
## Ziel
|
||||
|
||||
`NowPlayingScreen` wird Teil der dauerhaften App-Hülle (`HomeShell`) statt
|
||||
einer über den Navigator gepushten Route. Ein geteilter Fortschritts-Wert
|
||||
(0.0 = eingeklappt/Mini-Player, 1.0 = Vollbild) steuert live:
|
||||
- die Größe/Position des Vollbild-Inhalts (Interpolation Mini-Player-Rechteck
|
||||
→ Vollbild)
|
||||
- die Blur-Intensität des darunterliegenden Tab-Inhalts
|
||||
- das Ein-/Ausblenden des Mini-Players
|
||||
|
||||
Sowohl das Öffnen (Ziehen am Mini-Player, Tap an allen 4 bisherigen
|
||||
Öffnen-Stellen) als auch das Schließen (Ziehen im Vollbild-Player nach
|
||||
unten) sollen sich darüber gleich, live und symmetrisch anfühlen.
|
||||
|
||||
## Nicht-Ziele
|
||||
|
||||
- Keine Änderung an der Wiedergabe-Logik selbst (`playSongs`, `audio_handler.dart`).
|
||||
- Keine Änderung an internen `Navigator.push`-Aufrufen INNERHALB von
|
||||
`NowPlayingScreen` (Warteschlange, Songtext-Sheet) — die bleiben echte
|
||||
Routen/Modals über demselben Navigator.
|
||||
- Keine Änderung an `wischen.dart`s bestehender Entscheidungslogik
|
||||
(`oeffnetVollbildMitStrecke`, `titelWechselMitStrecke`,
|
||||
`wischSchwelle`/`wischStreckeSchwelle`) — die wird unverändert
|
||||
wiederverwendet, nur um einen rein optischen `progress`-Wert ergänzt.
|
||||
- Keine Änderung an Punkt 1 (`EinblendItem`, bereits umgesetzt/gemergt).
|
||||
- Punkte 3–5 des Animations-Berichts (Karaoke-Highlight, Advanced-Blur-
|
||||
Kopplung am Farbverlauf, Equalizer-Visualizer) sind nicht Teil dieses
|
||||
Specs.
|
||||
|
||||
## Entscheidungen (aus dem Brainstorming, mit Dustin abgestimmt)
|
||||
|
||||
| Frage | Entscheidung |
|
||||
|---|---|
|
||||
| Architektur-Ansatz | Persistentes Overlay in `HomeShell` statt interaktiver `PageRoute` |
|
||||
| Tap-Only-Stellen (song_list.dart, playlist_detail_screen.dart, server_titel_screen.dart) | Bekommen denselben Übergang (automatischer `progress` 0→1), nicht nur die Mini-Player-Geste |
|
||||
| Schließgeste im Vollbild-Player | Wird symmetrisch umgebaut — live am Finger, nicht nur Entscheidung bei Loslassen |
|
||||
| Cover-Übergang (bisher `Hero`) | Wird von Hand nachgebaut (Rect-Interpolation über `progress`), da `Hero` nur bei echten Navigator-Transitions feuert |
|
||||
| Android-Zurück-Taste | `PopScope` in `HomeShell`: bei `progress > 0` schließt Zurück den Player statt die Route/App zu verlassen — notwendiger Teil des Designs, keine Alternative |
|
||||
|
||||
## Architektur
|
||||
|
||||
### Neue Komponente: `PlayerExpansionController`
|
||||
|
||||
Datei: `lib/player/player_expansion_controller.dart`. `ChangeNotifier`,
|
||||
lebt in `HomeShell`s `State` (die `TickerProviderStateMixin` bekommt, um
|
||||
als `vsync` für einen internen `AnimationController` zu dienen), bereit-
|
||||
gestellt per `ChangeNotifierProvider.value` oberhalb von `IndexedStack`,
|
||||
`MiniPlayer` und `NowPlayingScreen`.
|
||||
|
||||
Zustand:
|
||||
- `double progress` — 0.0…1.0, rein optisch, öffentlich lesbar.
|
||||
|
||||
Methoden:
|
||||
- `dragBy(double dy)` — während des Ziehens aufgerufen (Mini-Player beim
|
||||
Öffnen, Vollbild-Player beim Schließen, jeweils mit passendem Vorzeichen).
|
||||
Bildet `progress = (aufsummierte Zugstrecke / referenzHoehe).clamp(0, 1)`.
|
||||
`referenzHoehe` ist ein fester Wert (300px) — bewusst **entkoppelt** von
|
||||
der Entscheidungs-Schwelle in `wischen.dart`; er bestimmt nur, wie "weit"
|
||||
sich der optische Übergang bei einer bestimmten Zugstrecke anfühlt.
|
||||
- `dragEnd(double geschwindigkeit, double strecke)` — ruft unverändert
|
||||
`oeffnetVollbildMitStrecke`/die Schließ-Variante auf, um zu entscheiden,
|
||||
ob `progress` zu 1.0 oder 0.0 animiert wird (`AnimationController.fling`
|
||||
mit der Loslass-Geschwindigkeit als Startimpuls, danach `MeloMotion.curve`
|
||||
bis zum jeweiligen Ziel).
|
||||
- `open()` — animiert `progress` 0→1 über `MeloMotion.ruhig(context,
|
||||
MeloMotion.normal)` (Tap-Auslöser, kein Ziehen beteiligt).
|
||||
- `close()` — spiegelbildlich 1→0 (z.B. Zurück-Taste, `X`-artiger Schließen-
|
||||
Button falls später gewünscht).
|
||||
- Reduce-Motion: alle Übergänge über `MeloMotion.ruhig` — bei deaktivierten
|
||||
Animationen springt `progress` sofort auf das Ziel.
|
||||
|
||||
### Widget-Baum in `HomeShell`
|
||||
|
||||
Heute: `Scaffold` mit `body`-Bereich, der `IndexedStack` (5 Tabs) + darunter
|
||||
`MiniPlayer` + `BottomNavigationBar` anordnet. Neu: der `body`-Bereich wird
|
||||
ein `Stack`, von unten nach oben:
|
||||
|
||||
1. `IndexedStack` (Tabs) + `BottomNavigationBar` — unverändert
|
||||
2. `AnimatedBuilder` auf den Controller: nur wenn `progress > 0`, ein
|
||||
`BackdropFilter(filter: ImageFilter.blur(sigmaX: progress * maxSigma,
|
||||
sigmaY: progress * maxSigma))` über Punkt 1 — Widget wird bei
|
||||
`progress == 0` komplett aus dem Baum entfernt (kein Performance-Overhead
|
||||
im Ruhezustand). `maxSigma = 20.0` als Startwert (spürbarer Weichzeichner,
|
||||
ohne die Tab-Umrisse völlig zu verlieren) — im Review/auf echtem Gerät
|
||||
nachjustierbar, siehe „Offene Risiken“.
|
||||
3. `MiniPlayer` — `Opacity(opacity: 1 - progress)`, bei `progress == 1`
|
||||
ebenfalls aus dem Baum entfernt (`IgnorePointer`/Kein Hit-Testing mehr,
|
||||
sonst blockiert eine unsichtbare Leiste Gesten im Vollbild-Player)
|
||||
4. `NowPlayingScreen`-Inhalt — Größe/Position per `Rect.lerp` zwischen dem
|
||||
Mini-Player-Rechteck (volle Breite, `MiniPlayer.hoehe` + Fortschrittsbalken
|
||||
hoch, am unteren Rand über der `BottomNavigationBar`) und dem
|
||||
Vollbild-Rechteck; Detail-Inhalt (Titel, Steuerung, Songtext-Icon etc.)
|
||||
blendet erst ab `progress > 0.3` ein (Crossfade), darunter ist nur das
|
||||
wandernde Cover sichtbar — vermeidet, dass die volle Player-UI in eine
|
||||
72px hohe Box gequetscht wird
|
||||
|
||||
### Cover-Übergang ohne `Hero`
|
||||
|
||||
`_Cover`/`Hero(tag: coverHeldenName, ...)` entfällt an beiden Stellen.
|
||||
Stattdessen: eine neue `_WanderndesCover`-Komponente, die ihr Rechteck
|
||||
(Größe + Position) direkt aus `progress` berechnet (`Rect.lerp` zwischen
|
||||
Mini- und Vollbild-Cover-Rect) und denselben `CoverImage`-Widget-Typ mit
|
||||
interpolierendem `radius` (6 → 16) rendert. Lebt als Teil der `NowPlayingScreen`-
|
||||
Inhalts-Ebene (Punkt 4 oben) — der `MiniPlayer` selbst zeigt kein eigenes
|
||||
Cover mehr, sobald `progress > 0`, um Doppel-Rendering zu vermeiden.
|
||||
|
||||
### Migration der 4 Öffnen-Stellen
|
||||
|
||||
- `lib/library/song_list.dart` (`SongZeile._zeile.onTap`),
|
||||
`lib/playlists/playlist_detail_screen.dart`,
|
||||
`lib/downloads/server_titel_screen.dart`: `Navigator.push(MaterialPageRoute(
|
||||
builder: (_) => const NowPlayingScreen()))` wird durch
|
||||
`context.read<PlayerExpansionController>().open()` ersetzt — nur im
|
||||
Erfolgspfad nach `playSongs(...)`, Fehlerpfad/Snackbar unverändert. Der
|
||||
`NowPlayingScreen`-Import entfällt an diesen 3 Stellen vollständig.
|
||||
- `lib/player/mini_player.dart`: `_oeffne()` ruft `.open()` statt zu pushen;
|
||||
`onVerticalDragUpdate`/`onVerticalDragEnd` rufen `.dragBy()`/`.dragEnd()`
|
||||
statt lokal `_versatz` zu verwalten (die bisherige rein visuelle
|
||||
Mitzieh-Logik der Leiste geht im gemeinsamen `progress`-Wert auf).
|
||||
|
||||
### Zurück-Taste (Android)
|
||||
|
||||
`HomeShell` bekommt ein `PopScope(canPop: controller.progress == 0,
|
||||
onPopInvokedWithResult: (did, _) { if (!did) controller.close(); })` (oder
|
||||
Äquivalent je nach Flutter-Version im Projekt) um den `Scaffold`/`Stack`.
|
||||
Bei offenem oder halb gezogenem Player schließt Zurück den Player; erst bei
|
||||
`progress == 0` verhält sich Zurück wie heute (App verlassen/vorherige
|
||||
Route).
|
||||
|
||||
## Verhaltensänderung (positiv, aber erwähnenswert)
|
||||
|
||||
`_CoverGrundState` (Farbverlauf-Hintergrund aus dem Cover) läuft heute bei
|
||||
jedem Öffnen neu, weil jede Route ein frischer State ist. Als dauerhaftes
|
||||
Overlay bleibt der State über mehrere Öffnen/Schließen-Zyklen hinweg
|
||||
erhalten — die Cover-Farbe ist beim erneuten Öffnen sofort da, kein
|
||||
wiederholtes Bild-Dekodieren pro Öffnen. Reine Verbesserung, aber ein
|
||||
beobachtbarer Unterschied gegenüber heute.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit** (`test/player/player_expansion_controller_test.dart`):
|
||||
`dragBy`/`dragEnd`-Übergänge, Clamping auf [0,1], Reduce-Motion-Sofortsprung,
|
||||
`open()`/`close()`-Zielwerte. Entscheidungslogik selbst bleibt in
|
||||
`wischen.dart` und wird dort NICHT erneut getestet (unverändert).
|
||||
- **Widget** (`test/player/home_shell_expansion_test.dart` o.ä.):
|
||||
- Ziehen am Mini-Player erhöht `progress`, Blur-Layer erscheint ab
|
||||
`progress > 0`
|
||||
- Tap aus `song_list.dart` (bzw. Stellvertreter-Widget im Test) ruft
|
||||
`.open()` auf, kein `Navigator.push` mehr
|
||||
- Zurück-Taste bei `progress > 0` schließt den Player statt zu poppen/die
|
||||
App zu verlassen
|
||||
- Ziehen nach unten im Vollbild-Player reduziert `progress` symmetrisch
|
||||
bis zum Schließen
|
||||
- **Cover-Interpolation**: Test, dass `_WanderndesCover` bei
|
||||
`progress = 0 / 0.5 / 1` die erwarteten Rect/Radius-Werte liefert
|
||||
(`Rect.lerp`-Ergebnis direkt prüfbar, kein Golden-Test nötig)
|
||||
|
||||
## Offene Risiken
|
||||
|
||||
- `BackdropFilter` ist in Flutter performance-sensibel (GPU-Kosten pro
|
||||
Frame) — bewusst nur im Baum, wenn `progress > 0`, um Idle-Kosten zu
|
||||
vermeiden; bei sehr low-end Geräten ggf. später Sigma-Obergrenze
|
||||
reduzieren, falls sich das Ziehen ruckelig anfühlt (kein Blocker für die
|
||||
erste Umsetzung, aber im Review beobachten).
|
||||
- `PlayerExpansionController` muss den `mounted`-Zustand von `HomeShell`
|
||||
respektieren (kein `notifyListeners()`/Controller-Zugriff nach `dispose()`)
|
||||
— analog zum bestehenden Muster in `_CoverGrundState`.
|
||||
|
||||
## Nachtrag: Review-Panel (2026-08-29, vor Umsetzung)
|
||||
|
||||
Ein Budget-Adversarial-Panel (3 Reviewer + Synthese) hat den Implementierungsplan
|
||||
gegen diese Spec geprüft und dabei Lücken gefunden, die der Plan direkt behebt
|
||||
(Details im Implementierungsplan-Dokument, nicht hier wiederholt):
|
||||
|
||||
- Hit-Test- und Screenreader-Isolation zum Hintergrund waren in der ersten
|
||||
Planfassung nicht vorgesehen — jetzt Teil von Task 4 (`IgnorePointer`/
|
||||
`ExcludeSemantics` auf Tab-Inhalt und `BottomNavigationBar` bei `progress > 0`).
|
||||
- Der Wegfall der bisher über `Scaffold`/`AppBar` impliziten Status-Icon-
|
||||
Helligkeit war nicht bedacht — jetzt Teil von Task 5 (`AnnotatedRegion`).
|
||||
- Ohne `extendBody: true` hätte das "Vollbild"-Rechteck nicht zur tatsächlichen
|
||||
`Scaffold.body`-Höhe gepasst — jetzt Teil von Task 4.
|
||||
- Erwogene, aber verworfene Alternative: ein nur *während der Geste*
|
||||
eingeblendeter `OverlayEntry` (Blur + Cover-Vorschau) direkt in
|
||||
`mini_player.dart`, der bei Erreichen der Öffnen-Schwelle in einen normalen
|
||||
`Navigator.push` mit unverändertem, Hero-basiertem `NowPlayingScreen`
|
||||
übergeht — deutlich weniger Code, behält Route-Semantik (Accessibility-
|
||||
Grenze, einfacheres Zurück-Verhalten) vollständig. Nicht gewählt, weil
|
||||
Dustin sich für die Overlay-Variante (näher an Spotify/Apple Music, siehe
|
||||
oben) entschieden hat — hier dokumentiert, damit die Abwägung nachvollziehbar
|
||||
bleibt, falls der Wartungsaufwand des Overlay-Ansatzes sich später als zu
|
||||
hoch erweist.
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+566
-139
@@ -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,35 @@ 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/gast_zugang.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 +48,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 +105,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 +124,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 +139,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 +161,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 +211,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(
|
||||
@@ -153,7 +225,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
const Icon(Icons.cloud_off, size: 64, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Musikserver nicht verbunden',
|
||||
style: TextStyle(color: Colors.white54)),
|
||||
style: TextStyle(color: MeloTheme.text2)),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Gehe zu Einstellungen → Navidrome zum Verbinden',
|
||||
style: TextStyle(color: Colors.white30, fontSize: 12)),
|
||||
@@ -164,7 +236,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
|
||||
if (_loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.redAccent),
|
||||
child: CircularProgressIndicator(color: MeloTheme.red),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,12 +250,12 @@ 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',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white54),
|
||||
style: const TextStyle(color: MeloTheme.text2),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
@@ -199,6 +271,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const _LadeFortschritt(),
|
||||
if (_neuheiten.neue.isNotEmpty)
|
||||
MaterialBanner(
|
||||
backgroundColor: MeloTheme.surfaceHigh,
|
||||
@@ -228,9 +301,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 +315,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 +336,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 +371,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 +389,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 +587,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 +607,55 @@ 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;
|
||||
|
||||
/// Fehlertext, falls das Holen des Gast-Tokens scheitert.
|
||||
String? _gastFehler;
|
||||
|
||||
/// Verhindert Mehrfach-Anfragen bei schnellem Doppel-Antippen.
|
||||
bool _gastLaeuft = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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 +686,26 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
||||
}
|
||||
|
||||
Future<void> _anmelden() async {
|
||||
final auth = context.read<BakaAuth>();
|
||||
final daten = await showDialog<(String, String)>(
|
||||
final nav = context.read<DownloadService>().navidrome;
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (_) => const _AnmeldeDialog(),
|
||||
builder: (_) => _AnmeldeDialog(benutzer: nav.benutzer),
|
||||
);
|
||||
if (daten == null || !mounted) return;
|
||||
final fehler = await auth.anmelden(daten.$1, daten.$2);
|
||||
}
|
||||
|
||||
Future<void> _alsGastFortfahren() async {
|
||||
if (_gastLaeuft) return;
|
||||
final gast = context.read<GastZugang>();
|
||||
setState(() {
|
||||
_gastFehler = null;
|
||||
_gastLaeuft = true;
|
||||
});
|
||||
final fehler = await gast.holeToken();
|
||||
if (!mounted) return;
|
||||
setState(() => _meldung = fehler);
|
||||
setState(() {
|
||||
_gastLaeuft = false;
|
||||
if (fehler != null) _gastFehler = fehler;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _herunterladen() async {
|
||||
@@ -480,39 +759,115 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<BakaAuth>();
|
||||
final gast = context.watch<GastZugang>();
|
||||
final dienst = context.watch<YtDownloadService>();
|
||||
// Server-User gelten sofort als berechtigt — die Anmeldung passiert für
|
||||
// sie im Hintergrund (_pruefeServerUser). Nur wenn die automatische
|
||||
// Anmeldung nachweislich fehlgeschlagen ist, bleibt der manuelle Weg
|
||||
// als Rückfalloption sichtbar — sonst gäbe es keinen Weg mehr zurück.
|
||||
// Ein Gast-Token zählt ebenfalls als Zugang, nur mit Tages-Limit.
|
||||
final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken;
|
||||
final zugriffOk = auth.istAngemeldet ||
|
||||
(_serverUser && !_autoLoginFehlgeschlagen) ||
|
||||
istGastModus;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
children: [
|
||||
if (!auth.istAngemeldet) ...[
|
||||
const Text(
|
||||
'Der Downloader läuft über den Baka-Server. Dafür brauchst du '
|
||||
'deine Baka-Anmeldung.',
|
||||
style: TextStyle(color: Colors.white54, 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: Colors.white38),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text('Angemeldet als ${auth.benutzer}',
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 13)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: auth.abmelden,
|
||||
child: const Text('Abmelden'),
|
||||
if (!_serverUser) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: MeloTheme.minTouchTarget + 4,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.person_outline),
|
||||
label: const Text('Als Gast fortfahren (5 Downloads/Tag)',
|
||||
style: TextStyle(fontSize: 16)),
|
||||
onPressed: _gastLaeuft ? null : _alsGastFortfahren,
|
||||
),
|
||||
),
|
||||
if (_gastFehler != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(_gastFehler!,
|
||||
style: const TextStyle(color: MeloTheme.red, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
] else ...[
|
||||
if (!_serverUser && auth.istAngemeldet) ...[
|
||||
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 (auth.istAngemeldet && auth.verbleibend != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.cloud_outlined, size: 18, color: MeloTheme.text3),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Noch ${auth.verbleibend} von 100 heute',
|
||||
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (istGastModus) ...[
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.person_outline, size: 18, color: MeloTheme.text3),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
gast.verbleibend == null
|
||||
? 'Als Gast unterwegs (5 Downloads/Tag)'
|
||||
: 'Als Gast unterwegs — noch ${gast.verbleibend} von 5 heute',
|
||||
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: gast.verwerfen,
|
||||
child: const Text('Anmelden'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
TextField(
|
||||
controller: _url,
|
||||
enabled: !dienst.laeuft,
|
||||
@@ -524,14 +879,15 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_KategorieFeld(controller: _kategorie, aktiv: !dienst.laeuft),
|
||||
SwitchListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('YouTube-Cookies des Servers verwenden'),
|
||||
subtitle: const Text('Nötig für altersbeschränkte Videos'),
|
||||
value: _cookies,
|
||||
onChanged: dienst.laeuft ? null : _setzeCookies,
|
||||
),
|
||||
if (!istGastModus)
|
||||
SwitchListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('YouTube-Cookies des Servers verwenden'),
|
||||
subtitle: const Text('Nötig für altersbeschränkte Videos'),
|
||||
value: _cookies,
|
||||
onChanged: dienst.laeuft ? null : _setzeCookies,
|
||||
),
|
||||
if (_orte.length > 1) ...[
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonFormField<String>(
|
||||
@@ -563,13 +919,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: Colors.white54, 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: Colors.white70, fontSize: 13)),
|
||||
style: TextStyle(
|
||||
color: _meldung!.startsWith('✅')
|
||||
? MeloTheme.text2
|
||||
: MeloTheme.red,
|
||||
fontSize: 13)),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -578,16 +951,22 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
||||
|
||||
/// Fragt Benutzername und Passwort ab und gibt beides zurück.
|
||||
class _AnmeldeDialog extends StatefulWidget {
|
||||
const _AnmeldeDialog();
|
||||
const _AnmeldeDialog({this.benutzer = ''});
|
||||
|
||||
/// Bereits bekannter Navidrome-Benutzername (leer, falls keiner bekannt) —
|
||||
/// vorausgefüllt statt immer "Baka" zu zeigen, egal wer die App nutzt.
|
||||
final String benutzer;
|
||||
|
||||
@override
|
||||
State<_AnmeldeDialog> createState() => _AnmeldeDialogState();
|
||||
}
|
||||
|
||||
class _AnmeldeDialogState extends State<_AnmeldeDialog> {
|
||||
final _benutzer = TextEditingController(text: 'Baka');
|
||||
late final _benutzer = TextEditingController(text: widget.benutzer);
|
||||
final _passwort = TextEditingController();
|
||||
bool _sichtbar = false;
|
||||
bool _verbindet = false;
|
||||
String? _fehler;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -596,6 +975,32 @@ 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) {
|
||||
// Navidrome- und Baka-Konto teilen sich die Zugangsdaten (siehe
|
||||
// navidrome_service.dart) — ohne diese Übernahme würde der nächste
|
||||
// Auto-Login nach Ablauf des Baka-Tokens wieder mit dem alten
|
||||
// Passwort scheitern.
|
||||
final nav = context.read<DownloadService>().navidrome;
|
||||
await nav.speichereZugangsdaten(
|
||||
nav.serverUrl, _benutzer.text.trim(), _passwort.text);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_verbindet = false;
|
||||
_fehler = fehler;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
@@ -605,10 +1010,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 +1025,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 +1097,4 @@ class _KategorieFeld extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
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 '../player/player_expansion_controller.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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (!mounted) return;
|
||||
context.read<PlayerExpansionController>().open(context);
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
});
|
||||
|
||||
final SubsonicSong song;
|
||||
final int nummer;
|
||||
final bool geladen;
|
||||
final VoidCallback onTap;
|
||||
|
||||
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: [
|
||||
if (geladen)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: MeloSpace.sm),
|
||||
child: Icon(Icons.download_done,
|
||||
size: 16, color: MeloTheme.text3),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
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/gast_zugang.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;
|
||||
|
||||
/// Fehlertext, falls das Holen des Gast-Tokens scheitert.
|
||||
String? _gastFehler;
|
||||
|
||||
/// Verhindert Mehrfach-Anfragen bei schnellem Doppel-Antippen.
|
||||
bool _gastLaeuft = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _alsGastFortfahren() async {
|
||||
if (_gastLaeuft) return;
|
||||
final gast = context.read<GastZugang>();
|
||||
setState(() {
|
||||
_gastFehler = null;
|
||||
_gastLaeuft = true;
|
||||
});
|
||||
final fehler = await gast.holeToken();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_gastLaeuft = false;
|
||||
if (fehler != null) _gastFehler = fehler;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_query.dispose();
|
||||
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 gast = context.watch<GastZugang>();
|
||||
final suche = context.watch<YtSearchService>();
|
||||
final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken;
|
||||
final zugriffOk = auth.istAngemeldet ||
|
||||
(_serverUser && !_autoLoginFehlgeschlagen) ||
|
||||
istGastModus;
|
||||
|
||||
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) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Text(
|
||||
_serverUser
|
||||
? 'Die Suche läuft über den Baka-Server. Dafür brauchst '
|
||||
'du deine Baka-Anmeldung (siehe Download-Tab).'
|
||||
: 'Die Suche läuft über den Baka-Server. Melde dich mit '
|
||||
'deinem Baka-Konto an (siehe Download-Tab) oder '
|
||||
'nutze sie eingeschränkt als Gast.',
|
||||
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
|
||||
),
|
||||
),
|
||||
if (!_serverUser) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: MeloTheme.minTouchTarget + 4,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.person_outline),
|
||||
label: const Text('Als Gast fortfahren (5 Downloads/Tag)',
|
||||
style: TextStyle(fontSize: 16)),
|
||||
onPressed: _gastLaeuft ? null : _alsGastFortfahren,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_gastFehler != null)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Text(_gastFehler!,
|
||||
style:
|
||||
const TextStyle(color: MeloTheme.red, fontSize: 13)),
|
||||
),
|
||||
],
|
||||
],
|
||||
if (auth.istAngemeldet && auth.verbleibend != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cloud_outlined, size: 18, color: MeloTheme.text3),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Noch ${auth.verbleibend} von 100 heute',
|
||||
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (istGastModus)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
gast.verbleibend == null
|
||||
? 'Als Gast unterwegs (5 Downloads/Tag)'
|
||||
: 'Als Gast unterwegs — noch ${gast.verbleibend} von 5 heute',
|
||||
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: gast.verwerfen,
|
||||
child: const Text('Anmelden'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (suche.laeuft) const LinearProgressIndicator(),
|
||||
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')}';
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+206
-21
@@ -1,24 +1,42 @@
|
||||
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/einblend_item.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';
|
||||
|
||||
/// 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: Colors.white54)),
|
||||
child: Text('Keine Künstler',
|
||||
style: TextStyle(color: MeloTheme.text2)),
|
||||
);
|
||||
}
|
||||
final grouped = groupByArtist(songs);
|
||||
@@ -27,16 +45,9 @@ 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 EinblendItem(
|
||||
index: i,
|
||||
child: _KuenstlerZeile(name: artist, songs: grouped[artist]!),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -45,17 +56,191 @@ 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) {
|
||||
Widget zeile() {
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
return EinblendItem(index: i, child: zeile());
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ 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.
|
||||
///
|
||||
@@ -29,7 +30,7 @@ class CategoryListScreen extends StatelessWidget {
|
||||
if (songs.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Keine Kategorien',
|
||||
style: TextStyle(color: Colors.white54)),
|
||||
style: TextStyle(color: MeloTheme.text2)),
|
||||
);
|
||||
}
|
||||
final grouped = groupByCategory(
|
||||
@@ -56,7 +57,7 @@ class CategoryListScreen extends StatelessWidget {
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
_KategorieLiederScreen(name: name, songs: lieder),
|
||||
TitelListenScreen(titel: name, songs: lieder),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -66,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+84
-17
@@ -117,6 +117,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 +142,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 => 10;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -169,6 +187,9 @@ class MeloDb extends _$MeloDb {
|
||||
// Dateien ändert.
|
||||
await _ergaenzeAlbumKategorien();
|
||||
}
|
||||
if (from < 10) {
|
||||
await m.createTable(downloads);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -191,24 +212,36 @@ 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();
|
||||
/// Überwacht die zuletzt GEHÖRTEN Songs (nicht getombstonte), aus
|
||||
/// [PlaybackHistory]. Ein mehrfach gespielter Song erscheint nur einmal —
|
||||
/// an der Position seines jüngsten Abspielens. Dafür wird pro [songId]
|
||||
/// zuerst der jüngste `playedAtMs`-Eintrag ermittelt (Subquery mit
|
||||
/// `GROUP BY`), und darüber zurück auf [songs] verknüpft.
|
||||
Stream<List<Song>> watchRecentlyPlayed({int limit = 50}) {
|
||||
final letzterPlay = playbackHistory.playedAtMs.max();
|
||||
final letztePlaysProSong = selectOnly(playbackHistory)
|
||||
..addColumns([playbackHistory.songId, letzterPlay])
|
||||
..groupBy([playbackHistory.songId]);
|
||||
final subquery = Subquery(letztePlaysProSong, 'letzte_plays');
|
||||
final letzterPlayRef = subquery.ref(letzterPlay);
|
||||
|
||||
final query = select(songs).join([
|
||||
innerJoin(
|
||||
subquery,
|
||||
subquery.ref(playbackHistory.songId).equalsExp(songs.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(songs.deleted.equals(false))
|
||||
..orderBy([
|
||||
OrderingTerm(expression: letzterPlayRef, mode: OrderingMode.desc)
|
||||
])
|
||||
..limit(limit);
|
||||
|
||||
return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList());
|
||||
}
|
||||
|
||||
|
||||
/// Alle Songs inkl. getombstonte. Intern für ID-Stabilität beim Scan.
|
||||
Future<List<Song>> allSongs() => select(songs).get();
|
||||
|
||||
@@ -457,6 +490,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();
|
||||
@@ -541,6 +579,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))
|
||||
|
||||
@@ -2767,6 +2767,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 +3213,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 +3226,7 @@ abstract class _$MeloDb extends GeneratedDatabase {
|
||||
favorites,
|
||||
playbackHistory,
|
||||
songCategories,
|
||||
downloads,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5324,6 +5760,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 +5997,6 @@ class $MeloDbManager {
|
||||
$$PlaybackHistoryTableTableManager(_db, _db.playbackHistory);
|
||||
$$SongCategoriesTableTableManager get songCategories =>
|
||||
$$SongCategoriesTableTableManager(_db, _db.songCategories);
|
||||
$$DownloadsTableTableManager get downloads =>
|
||||
$$DownloadsTableTableManager(_db, _db.downloads);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ 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';
|
||||
|
||||
/// Favoriten-Tab: Shuffle-Wiedergabe und dieselbe Sortierung wie "Meine Musik",
|
||||
/// nur auf den favorisierten Songs.
|
||||
@@ -17,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(),
|
||||
@@ -55,7 +50,7 @@ class _Empty extends StatelessWidget {
|
||||
children: [
|
||||
Icon(Icons.favorite_border, size: 64, color: Colors.white24),
|
||||
SizedBox(height: 12),
|
||||
Text('Noch keine Favoriten', style: TextStyle(color: Colors.white54)),
|
||||
Text('Noch keine Favoriten', style: TextStyle(color: MeloTheme.text2)),
|
||||
SizedBox(height: 6),
|
||||
Text('Tippe in einer Liederliste auf das Herz.',
|
||||
style: TextStyle(color: Colors.white30, fontSize: 12)),
|
||||
@@ -64,3 +59,4 @@ class _Empty extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,13 @@ import 'scan_service.dart';
|
||||
String get addMusicLabel =>
|
||||
Platform.isAndroid ? 'Musik scannen' : 'Musikordner hinzufügen';
|
||||
|
||||
/// Ob der "Erneut scannen"-Button zusätzlich zum [addMusicLabel]-Button
|
||||
/// angezeigt wird. Auf Android tun [LibraryService.pickFolderAndScan] und
|
||||
/// [LibraryService.rescan] dasselbe (kompletter MediaStore-Scan) — dort
|
||||
/// reicht ein Button. Pure Funktion statt direkter [Platform.isAndroid]-
|
||||
/// Abfrage, damit sie unabhängig von der echten Plattform testbar ist.
|
||||
bool zeigeErneutScannenButton(bool isAndroid) => !isAndroid;
|
||||
|
||||
/// Koordiniert Ordnerwahl und Scans; hält den Scan-Fortschritt für die UI.
|
||||
/// Android scannt automatisch über MediaStore (Scoped Storage lässt keinen
|
||||
/// direkten Dateizugriff zu); Desktop scannt vom Nutzer gewählte Ordner.
|
||||
@@ -25,8 +32,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 +74,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('ACRCloud-Zugang (steht in deinen Notizen)',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
style: TextStyle(color: MeloTheme.text2, fontSize: 12)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: accessCtrl,
|
||||
@@ -226,7 +226,7 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
|
||||
const Icon(Icons.mic, color: MeloTheme.red, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ich höre zu …',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
style: TextStyle(color: MeloTheme.text2)),
|
||||
const SizedBox(height: 8),
|
||||
Text('$_restSekunden',
|
||||
style: const TextStyle(
|
||||
@@ -240,7 +240,7 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
|
||||
CircularProgressIndicator(color: MeloTheme.red),
|
||||
SizedBox(height: 16),
|
||||
Text('Ich suche den Titel …',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
style: TextStyle(color: MeloTheme.text2)),
|
||||
],
|
||||
),
|
||||
_Phase.ergebnis => _treffer == null
|
||||
@@ -258,13 +258,13 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
|
||||
Text(_treffer!.kuenstler,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, color: Colors.white70)),
|
||||
fontSize: 16, color: MeloTheme.text2)),
|
||||
if (_treffer!.album.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(_treffer!.album,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: Colors.white38)),
|
||||
fontSize: 13, color: MeloTheme.text3)),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_nochmalKnopf(),
|
||||
@@ -280,7 +280,7 @@ class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
|
||||
const SizedBox(height: 16),
|
||||
Text(text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70)),
|
||||
style: const TextStyle(color: MeloTheme.text2)),
|
||||
const SizedBox(height: 20),
|
||||
_nochmalKnopf(),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
@@ -78,7 +92,7 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
|
||||
Text(
|
||||
'Scanne … ${lib.scanDone}'
|
||||
'${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}',
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: const TextStyle(color: MeloTheme.text2, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -99,7 +113,7 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text('Fehler: ${snapshot.error}',
|
||||
style: const TextStyle(color: Colors.white54)),
|
||||
style: const TextStyle(color: MeloTheme.text2)),
|
||||
);
|
||||
}
|
||||
final songs = snapshot.data ?? const <Song>[];
|
||||
@@ -150,11 +164,11 @@ class _Header extends StatelessWidget {
|
||||
child: const Row(
|
||||
children: [
|
||||
SizedBox(width: 14),
|
||||
Icon(Icons.search, color: Colors.white54, size: 20),
|
||||
Icon(Icons.search, color: MeloTheme.text2, size: 20),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'Titel, Künstler und Alben suchen',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 14),
|
||||
style: TextStyle(color: MeloTheme.text2, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -163,7 +177,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 +194,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.watchRecentlyPlayed(limit: 50),
|
||||
builder: (context, snapshot) {
|
||||
final recent = snapshot.data ?? const <Song>[];
|
||||
return _QuickCard(
|
||||
icon: Icons.schedule,
|
||||
label: 'Zuletzt',
|
||||
subtitle: 'Zuletzt gehört',
|
||||
coverPath: recent.isEmpty ? null : recent.first.coverPath,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const RecentlyPlayedScreen()),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -252,13 +273,19 @@ class _QuickCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: MeloSpace.xs, vertical: MeloSpace.sm),
|
||||
child: SizedBox(
|
||||
width: 150,
|
||||
child: Material(
|
||||
color: MeloTheme.surfaceHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
// Umriss statt Schatten — siehe MeloTheme.hairline.
|
||||
// shape statt borderRadius: Material verträgt nur eines von beiden.
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(MeloRadius.card),
|
||||
side: const BorderSide(color: MeloTheme.border),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Stack(
|
||||
@@ -274,7 +301,7 @@ class _QuickCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
padding: const EdgeInsets.all(MeloSpace.sm + MeloSpace.xs),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
@@ -294,7 +321,7 @@ class _QuickCard extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54, fontSize: 11)),
|
||||
color: MeloTheme.text2, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -323,7 +350,7 @@ class _Empty extends StatelessWidget {
|
||||
const Icon(Icons.library_music, size: 64, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Noch keine Musik',
|
||||
style: TextStyle(color: Colors.white54)),
|
||||
style: TextStyle(color: MeloTheme.text2)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.create_new_folder_outlined),
|
||||
@@ -336,23 +363,23 @@ class _Empty extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vollbild-Liste der zuletzt hinzugefügten Songs (Schnellzugriff "Zuletzt").
|
||||
class RecentlyAddedScreen extends StatelessWidget {
|
||||
const RecentlyAddedScreen({super.key});
|
||||
/// Vollbild-Liste der zuletzt gehörten Songs (Schnellzugriff "Zuletzt").
|
||||
class RecentlyPlayedScreen extends StatelessWidget {
|
||||
const RecentlyPlayedScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final db = context.read<MeloDb>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Zuletzt hinzugefügt')),
|
||||
appBar: AppBar(title: const Text('Zuletzt gehört')),
|
||||
body: StreamBuilder<List<Song>>(
|
||||
stream: db.watchRecent(limit: 100),
|
||||
stream: db.watchRecentlyPlayed(limit: 100),
|
||||
builder: (context, snapshot) {
|
||||
final songs = snapshot.data ?? const <Song>[];
|
||||
if (songs.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Noch nichts hinzugefügt',
|
||||
style: TextStyle(color: Colors.white54)),
|
||||
child: Text('Noch nichts abgespielt',
|
||||
style: TextStyle(color: MeloTheme.text2)),
|
||||
);
|
||||
}
|
||||
return SongList(songs);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+412
-35
@@ -1,9 +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 '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});
|
||||
|
||||
@@ -12,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: Colors.white54)),
|
||||
)
|
||||
: StreamBuilder<List<Song>>(
|
||||
stream: db.searchSongs(_query),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text('Fehler: ${snapshot.error}',
|
||||
style: const TextStyle(color: Colors.white54)),
|
||||
);
|
||||
}
|
||||
final songs = snapshot.data ?? const [];
|
||||
if (songs.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Nichts gefunden',
|
||||
style: TextStyle(color: Colors.white54)),
|
||||
);
|
||||
}
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,12 +62,12 @@ class SongDetailSheet extends StatelessWidget {
|
||||
fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
Text(song.artist ?? 'Unbekannter Künstler',
|
||||
style: const TextStyle(color: Colors.white70)),
|
||||
style: const TextStyle(color: MeloTheme.text2)),
|
||||
if (song.album != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(song.album!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white38, fontSize: 12)),
|
||||
color: MeloTheme.text3, fontSize: 12)),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -89,7 +89,7 @@ class SongDetailSheet extends StatelessWidget {
|
||||
),
|
||||
if (names.isEmpty)
|
||||
const Text('Keine Kategorie',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 13))
|
||||
style: TextStyle(color: MeloTheme.text3, fontSize: 13))
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
@@ -143,7 +143,7 @@ class _ExpertSection extends StatelessWidget {
|
||||
title: const Text('Expertenmodus',
|
||||
style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: const Text('Weitere Metadaten',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 12)),
|
||||
style: TextStyle(color: MeloTheme.text3, fontSize: 12)),
|
||||
children: [
|
||||
_Row('Titel', song.title),
|
||||
_Row('Künstler', song.artist ?? '—'),
|
||||
@@ -246,10 +246,10 @@ class _OnlineLookupState extends State<_OnlineLookup> {
|
||||
),
|
||||
if (vorschlaege != null && vorschlaege.isEmpty)
|
||||
const Text('Nichts gefunden',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 13)),
|
||||
style: TextStyle(color: MeloTheme.text3, fontSize: 13)),
|
||||
if (vorschlaege != null && vorschlaege.isNotEmpty) ...[
|
||||
const Text('Tippen übernimmt Titel, Künstler und Album',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 12)),
|
||||
style: TextStyle(color: MeloTheme.text3, fontSize: 12)),
|
||||
for (final vorschlag in vorschlaege)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
@@ -260,7 +260,7 @@ class _OnlineLookupState extends State<_OnlineLookup> {
|
||||
[vorschlag.kuenstler, vorschlag.album]
|
||||
.where((t) => t.isNotEmpty)
|
||||
.join(' — '),
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||||
style: const TextStyle(color: MeloTheme.text3, fontSize: 12),
|
||||
),
|
||||
onTap: () => _uebernehmen(vorschlag),
|
||||
),
|
||||
@@ -285,7 +285,7 @@ class _Row extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(label,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 13)),
|
||||
style: const TextStyle(color: MeloTheme.text3, fontSize: 13)),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value, style: const TextStyle(fontSize: 13)),
|
||||
@@ -368,12 +368,12 @@ class _CategoryEditorState extends State<_CategoryEditor> {
|
||||
children: [
|
||||
const Text(
|
||||
'Die erste Kategorie liefert das Coverbild.',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 12),
|
||||
style: TextStyle(color: MeloTheme.text3, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_names.isEmpty)
|
||||
const Text('Noch keine Kategorie',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 13))
|
||||
style: TextStyle(color: MeloTheme.text3, fontSize: 13))
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
@@ -399,7 +399,7 @@ class _CategoryEditorState extends State<_CategoryEditor> {
|
||||
if (offen.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Text('Bereits vergeben',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 12)),
|
||||
style: TextStyle(color: MeloTheme.text3, fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+263
-63
@@ -3,23 +3,153 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../player/audio_handler.dart';
|
||||
import '../player/player_expansion_controller.dart';
|
||||
import '../playlists/create_playlist_dialog.dart';
|
||||
import '../settings/app_settings.dart';
|
||||
import '../shared/cover.dart';
|
||||
import '../shared/einblend_item.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) => EinblendItem(
|
||||
index: i,
|
||||
child: 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 +205,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 +240,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 +253,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 +265,7 @@ class SongList extends StatelessWidget {
|
||||
title: const Text('Zu Wiedergabeliste hinzufügen'),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showAddToPlaylist(context, song);
|
||||
_showAddToPlaylist(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -145,57 +276,126 @@ 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 (!context.mounted) return;
|
||||
context.read<PlayerExpansionController>().open(context);
|
||||
if (uebersprungen > 0) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(offlineHinweis(uebersprungen))),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// A–Z zuerst, alles was nicht mit einem Buchstaben beginnt (Ziffern,
|
||||
/// Sonderzeichen) landet unter "#" am Ende.
|
||||
int _compareNames(String a, String b) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
+285
-24
@@ -1,10 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
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';
|
||||
@@ -17,12 +20,19 @@ import 'library/search_screen.dart';
|
||||
import 'player/audio_effects.dart';
|
||||
import 'player/audio_handler.dart';
|
||||
import 'player/mini_player.dart';
|
||||
import 'player/now_playing_screen.dart';
|
||||
import 'player/player_expansion_controller.dart';
|
||||
import 'services/baka_auth.dart';
|
||||
import 'services/logger_service.dart';
|
||||
import 'services/download_service.dart';
|
||||
import 'services/geteilte_adresse.dart';
|
||||
import 'services/gast_zugang.dart';
|
||||
import 'services/melo_cloud_service.dart';
|
||||
import 'services/offline_mode.dart';
|
||||
import 'services/navidrome_service.dart';
|
||||
import 'services/sync_service.dart';
|
||||
import 'services/yt_download_service.dart';
|
||||
import 'services/yt_search_service.dart';
|
||||
import 'settings/app_settings.dart';
|
||||
import 'shared/theme.dart';
|
||||
|
||||
@@ -35,8 +45,11 @@ late final CategoryService _categories;
|
||||
late final AppSettings _settings;
|
||||
late final AudioEffects _effects;
|
||||
late final BakaAuth _bakaAuth;
|
||||
late final GastZugang _gastZugang;
|
||||
late final YtDownloadService _ytDownload;
|
||||
late final YtSearchService _ytSearch;
|
||||
late final SyncService _sync;
|
||||
late final DownloadService _downloads;
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -56,18 +69,30 @@ 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();
|
||||
await _effects.init();
|
||||
_bakaAuth = BakaAuth();
|
||||
await _bakaAuth.laden();
|
||||
_ytDownload = YtDownloadService(auth: _bakaAuth);
|
||||
_gastZugang = GastZugang();
|
||||
await _gastZugang.laden();
|
||||
_ytDownload = YtDownloadService(auth: _bakaAuth, gast: _gastZugang);
|
||||
_ytSearch = YtSearchService(auth: _bakaAuth, gast: _gastZugang);
|
||||
_sync = SyncService(
|
||||
db: _db,
|
||||
cloud: MeloCloudService(auth: _bakaAuth),
|
||||
);
|
||||
await _sync.laden();
|
||||
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,17 +101,53 @@ 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());
|
||||
}
|
||||
|
||||
class MeloApp extends StatelessWidget {
|
||||
class MeloApp extends StatefulWidget {
|
||||
const MeloApp({super.key});
|
||||
|
||||
@override
|
||||
State<MeloApp> createState() => _MeloAppState();
|
||||
}
|
||||
|
||||
class _MeloAppState extends State<MeloApp> with TickerProviderStateMixin {
|
||||
// Muss oberhalb des Navigators bereitgestellt werden (Review-Fund): auch
|
||||
// per Navigator.push geöffnete Screens (z.B. PlaylistDetailScreen,
|
||||
// ServerTitelScreen) rufen .open(context) auf. Als HomeShell-internen
|
||||
// Provider steckte der Controller nur innerhalb von HomeShell.build() — für
|
||||
// eine gepushte Route (ein Geschwister von HomeShell im selben
|
||||
// Navigator/Overlay, kein Nachfahre) war er dort unsichtbar und
|
||||
// context.read<PlayerExpansionController>() warf ProviderNotFoundException.
|
||||
late final PlayerExpansionController _expansion;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_expansion = PlayerExpansionController(vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_expansion.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
@@ -100,8 +161,13 @@ class MeloApp extends StatelessWidget {
|
||||
ChangeNotifierProvider<AppSettings>.value(value: _settings),
|
||||
ChangeNotifierProvider<AudioEffects>.value(value: _effects),
|
||||
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
|
||||
ChangeNotifierProvider<GastZugang>.value(value: _gastZugang),
|
||||
ChangeNotifierProvider<YtDownloadService>.value(value: _ytDownload),
|
||||
ChangeNotifierProvider<YtSearchService>.value(value: _ytSearch),
|
||||
ChangeNotifierProvider<SyncService>.value(value: _sync),
|
||||
ChangeNotifierProvider<DownloadService>.value(value: _downloads),
|
||||
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||
value: _expansion),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'Melo',
|
||||
@@ -124,6 +190,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
|
||||
@@ -140,6 +209,19 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
// Beim Start einmal mit dem Server abgleichen — neue Titel von anderen
|
||||
// Geräten sind dann sofort da.
|
||||
context.read<SyncService>().automatisch();
|
||||
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
|
||||
@@ -155,6 +237,9 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
if (Platform.isAndroid) _autoScan.pruefe();
|
||||
context.read<SyncService>().automatisch();
|
||||
// Ein zweites Teilen startet die App nicht neu (singleTop), sondern
|
||||
// bringt sie nur nach vorn — deshalb auch hier nachsehen.
|
||||
unawaited(_pruefeGeteilteAdresse());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,33 +247,209 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
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(
|
||||
children: [
|
||||
Expanded(child: IndexedStack(index: _index, children: tabs)),
|
||||
const MiniPlayer(),
|
||||
],
|
||||
// Nur die Referenz holen, NICHT abonnieren (kein context.watch): die
|
||||
// Scaffold-weiten AnimatedBuilder unten hören direkt am Controller
|
||||
// (animation: expansion) und aktualisieren sich selbst live. Ein
|
||||
// context.watch hier würde stattdessen bei JEDEM _expansion-Tick den
|
||||
// kompletten HomeShell.build() (inkl. Scaffold-Rumpf) neu aufrufen.
|
||||
final expansion = context.read<PlayerExpansionController>();
|
||||
// Consumer NUR um PopScope — sein `child:` (der Scaffold-Rumpf) wird
|
||||
// laut Flutter-Semantik bei jedem expansion-Tick NICHT neu gebaut, das
|
||||
// ist hier auch nicht nötig: PopScope selbst braucht nur einen aktuellen
|
||||
// Wert bei jedem HomeShell-Rebuild (z.B. Zurück-Taste gedrückt), keine
|
||||
// Frame-genaue Aktualisierung während des Ziehens. Der Blur-Layer/Mini-
|
||||
// Player-Fade in Task 4 braucht dagegen echte Live-Reaktivität und
|
||||
// bekommt dafür einen eigenen, davon unabhängigen AnimatedBuilder direkt
|
||||
// an seiner Stelle im Stack — siehe Reaktivitäts-Regel in Global
|
||||
// Constraints.
|
||||
return Consumer<PlayerExpansionController>(
|
||||
builder: (context, expansion, child) => PopScope(
|
||||
// Offener/halb gezogener Player hat Vorrang vor dem Tab-Reset:
|
||||
// Zurück soll ihn zuerst schließen, nicht gleichzeitig auch noch
|
||||
// den Tab wechseln.
|
||||
canPop: expansion.progress == 0 && _index == 0,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
if (expansion.progress > 0) {
|
||||
expansion.close(context);
|
||||
} else {
|
||||
setState(() => _index = 0);
|
||||
}
|
||||
},
|
||||
child: child!,
|
||||
),
|
||||
bottomNavigationBar: 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'),
|
||||
],
|
||||
),
|
||||
);
|
||||
child: Scaffold(
|
||||
extendBody: true, // NowPlayingScreen (Task 5) braucht die volle Höhe bis unter die BottomNavigationBar
|
||||
body: Stack(
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: expansion,
|
||||
builder: (context, child) => IgnorePointer(
|
||||
ignoring: expansion.progress > 0,
|
||||
child: ExcludeSemantics(
|
||||
excluding: expansion.progress > 0,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(child: IndexedStack(index: _index, children: tabs)),
|
||||
const SizedBox(height: MiniPlayer.hoehe), // Platz für den Overlay-Mini-Player
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: expansion,
|
||||
builder: (context, child) {
|
||||
final p = expansion.progress;
|
||||
if (p == 0) return const SizedBox.shrink();
|
||||
return Positioned.fill(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: p * 20.0, sigmaY: p * 20.0),
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: expansion,
|
||||
builder: (context, child) => Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Opacity(
|
||||
opacity: (1 - expansion.progress).clamp(0.0, 1.0),
|
||||
child: IgnorePointer(
|
||||
ignoring: expansion.progress > 0,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const MiniPlayer(),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: expansion,
|
||||
builder: (context, child) {
|
||||
final p = expansion.progress;
|
||||
return Positioned.fromRect(
|
||||
rect: Rect.lerp(_miniRect(context), _vollbildRect(context), p)!,
|
||||
child: Visibility(
|
||||
visible: p > 0,
|
||||
maintainState: true, // _CoverGrundState bleibt erhalten, siehe Spec
|
||||
maintainAnimation: true,
|
||||
child: Opacity(
|
||||
// Detail-Inhalt blendet erst ab p > 0.3 ein, siehe Spec.
|
||||
opacity: ((p - 0.3) / 0.7).clamp(0.0, 1.0),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const NowPlayingScreen(),
|
||||
),
|
||||
StreamBuilder<MediaItem?>(
|
||||
stream: context.read<MeloAudioHandler>().mediaItem,
|
||||
builder: (context, snapshot) {
|
||||
final item = snapshot.data;
|
||||
if (item == null) return const SizedBox.shrink();
|
||||
return AnimatedBuilder(
|
||||
animation: expansion,
|
||||
builder: (context, child) => WanderndesCover(
|
||||
progress: expansion.progress,
|
||||
artUri: item.artUri,
|
||||
miniRect: _miniCoverRect(context),
|
||||
vollbildRect: _vollbildCoverRect(context),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
|
||||
// optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü.
|
||||
bottomNavigationBar: AnimatedBuilder(
|
||||
animation: expansion,
|
||||
builder: (context, child) => Opacity(
|
||||
opacity: (1 - expansion.progress).clamp(0.0, 1.0),
|
||||
child: IgnorePointer(
|
||||
ignoring: expansion.progress > 0,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(top: BorderSide(color: MeloTheme.border)),
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: _index,
|
||||
onTap: _goTo,
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.headphones), label: 'Meine Musik'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'),
|
||||
BottomNavigationBarItem(
|
||||
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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Rect _miniRect(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final unten = size.height - MiniPlayer.hoehe - kBottomNavigationBarHeight;
|
||||
return Rect.fromLTWH(0, unten, size.width, MiniPlayer.hoehe);
|
||||
}
|
||||
|
||||
Rect _vollbildRect(BuildContext context) => Offset.zero & MediaQuery.sizeOf(context);
|
||||
|
||||
/// Feste 52×52-Größe, linksbündig mit MeloSpace.md-Abstand, vertikal
|
||||
/// mittig in MiniPlayer.hoehe — Werte aus mini_player.dart übernommen
|
||||
/// (dortiges Cover entfällt, siehe Task 6 Step 5).
|
||||
Rect _miniCoverRect(BuildContext context) {
|
||||
final mini = _miniRect(context);
|
||||
const seite = 52.0;
|
||||
final top = mini.top + (MiniPlayer.hoehe - seite) / 2;
|
||||
return Rect.fromLTWH(mini.left + MeloSpace.md, top, seite, seite);
|
||||
}
|
||||
|
||||
/// Nicht per Formel bestimmbar (Review-Panel-Fund): `_CoverPlatzhalter`
|
||||
/// sitzt in NowPlayingScreens eigenem responsivem LayoutBuilder — die
|
||||
/// tatsächliche Position wird stattdessen gemessen, siehe
|
||||
/// coverPlatzhalterKey.
|
||||
Rect _vollbildCoverRect(BuildContext context) {
|
||||
final box = coverPlatzhalterKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) {
|
||||
// Vor dem ersten Layout (z.B. ganz am Anfang, progress noch 0) —
|
||||
// Notlösung: Platzhalter mittig im Vollbild-Rechteck annehmen, bis
|
||||
// der erste Frame mit progress > 0 eine echte Messung liefert.
|
||||
final voll = _vollbildRect(context);
|
||||
final seite = voll.width - 48;
|
||||
return Rect.fromLTWH(voll.left + 24, voll.top + 80, seite, seite);
|
||||
}
|
||||
return box.localToGlobal(Offset.zero) & box.size;
|
||||
}
|
||||
}
|
||||
|
||||
+363
-27
@@ -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,50 @@ 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);
|
||||
}
|
||||
|
||||
/// Übersetzung des Player-Shuffle-Zustands in den `audio_service`-Wert für
|
||||
/// `PlaybackState.shuffleMode`. Als reine Funktion ausgelagert, damit sie
|
||||
/// ohne laufenden `AudioPlayer` testbar ist — `_transformEvent()` läuft bei
|
||||
/// jedem Player-Event und lässt sich nicht isoliert aufrufen.
|
||||
AudioServiceShuffleMode toAudioServiceShuffleMode(bool enabled) =>
|
||||
enabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none;
|
||||
|
||||
/// Übersetzung des Player-Loop-Modus in den `audio_service`-Wert für
|
||||
/// `PlaybackState.repeatMode`. Siehe [toAudioServiceShuffleMode].
|
||||
AudioServiceRepeatMode toAudioServiceRepeatMode(LoopMode loopMode) =>
|
||||
switch (loopMode) {
|
||||
LoopMode.one => AudioServiceRepeatMode.one,
|
||||
LoopMode.all => AudioServiceRepeatMode.all,
|
||||
LoopMode.off => AudioServiceRepeatMode.none,
|
||||
};
|
||||
|
||||
/// Im Offline-Modus ist keiner der gewählten Titel ohne Netz abspielbar.
|
||||
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 +162,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 +184,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 +231,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]);
|
||||
@@ -142,6 +267,7 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
/// das in den Einstellungen erlaubt hat.
|
||||
Future<void> _watchHeadphones() async {
|
||||
final session = await AudioSession.instance;
|
||||
await session.configure(const AudioSessionConfiguration.music());
|
||||
session.devicesChangedEventStream.listen((event) {
|
||||
final added = event.devicesAdded.map((d) => d.type).toSet();
|
||||
if (shouldAutoPlay(
|
||||
@@ -159,51 +285,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 +406,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();
|
||||
}
|
||||
@@ -316,6 +650,8 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
}[_player.processingState] ??
|
||||
AudioProcessingState.idle,
|
||||
playing: _player.playing,
|
||||
shuffleMode: toAudioServiceShuffleMode(_player.shuffleModeEnabled),
|
||||
repeatMode: toAudioServiceRepeatMode(_player.loopMode),
|
||||
updatePosition: _player.position,
|
||||
bufferedPosition: _player.bufferedPosition,
|
||||
speed: _player.speed,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+220
-89
@@ -2,16 +2,19 @@ import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../shared/cover.dart';
|
||||
import '../shared/theme.dart';
|
||||
import 'audio_handler.dart';
|
||||
import 'now_playing_screen.dart';
|
||||
import 'player_expansion_controller.dart';
|
||||
import 'wischen.dart';
|
||||
|
||||
/// Kompakte Wiedergabe-Leiste über der Bottom-Nav.
|
||||
/// Blendet sich aus, wenn nichts läuft.
|
||||
class MiniPlayer extends StatelessWidget {
|
||||
const MiniPlayer({super.key});
|
||||
|
||||
/// Höhe der Zeile ohne den Fortschrittsbalken.
|
||||
static const double hoehe = 72;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
@@ -19,94 +22,222 @@ class MiniPlayer extends StatelessWidget {
|
||||
stream: handler.mediaItem,
|
||||
builder: (context, snapshot) {
|
||||
final item = snapshot.data;
|
||||
if (item == null) return const SizedBox.shrink();
|
||||
return Material(
|
||||
color: MeloTheme.surface,
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => const NowPlayingScreen(),
|
||||
)),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 80,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: CoverImage(artUri: item.artUri, size: 64, radius: 8),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.title,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(item.artist ?? 'Unbekannt',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
StreamBuilder<PlaybackState>(
|
||||
stream: handler.playbackState,
|
||||
builder: (context, snap) {
|
||||
final playing = snap.data?.playing ?? false;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
onPressed: handler.skipToPrevious,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: playing ? 'Pause' : 'Abspielen',
|
||||
icon: Icon(playing ? Icons.pause : Icons.play_arrow, size: 28),
|
||||
onPressed: playing ? handler.pause : handler.play,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: handler.skipToNext,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
StreamBuilder<Duration?>(
|
||||
stream: handler.durationStream,
|
||||
builder: (context, dSnap) {
|
||||
final duration = dSnap.data ?? Duration.zero;
|
||||
return StreamBuilder<Duration>(
|
||||
stream: handler.positionStream,
|
||||
builder: (context, pSnap) {
|
||||
final position = pSnap.data ?? Duration.zero;
|
||||
return LinearProgressIndicator(
|
||||
value: duration.inMilliseconds > 0
|
||||
? (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0)
|
||||
: 0,
|
||||
minHeight: 3,
|
||||
backgroundColor: Colors.white12,
|
||||
valueColor: const AlwaysStoppedAnimation(MeloTheme.red),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// Ein- und Ausblenden statt Aufpoppen: die Leiste kommt von unten
|
||||
// dazu, wenn etwas startet.
|
||||
return AnimatedSwitcher(
|
||||
duration: MeloMotion.ruhig(context, MeloMotion.normal),
|
||||
switchInCurve: MeloMotion.curve,
|
||||
transitionBuilder: (kind, animation) => SizeTransition(
|
||||
sizeFactor: animation,
|
||||
alignment: Alignment.topCenter,
|
||||
child: FadeTransition(opacity: animation, child: kind),
|
||||
),
|
||||
child: item == null
|
||||
? const SizedBox.shrink()
|
||||
: _Leiste(handler: handler, item: item),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
/// Für die horizontale Titel-Wechsel-Geste (unverändert).
|
||||
double _horizontalVersatz = 0;
|
||||
bool _ziehtHorizontalGerade = false;
|
||||
|
||||
/// Rohe, aufsummierte vertikale Zugstrecke seit Gestenbeginn — exakt wie
|
||||
/// bisher `_versatz.dy`, nur ohne die Optik-Verantwortung (die liegt jetzt
|
||||
/// im PlayerExpansionController). Wird für [PlayerExpansionController.
|
||||
/// dragEnd] gebraucht, das dieselbe rohe Strecke wie wischen.dart erwartet;
|
||||
/// aus `progress` ließe sich das wegen des Clampings nicht mehr exakt
|
||||
/// zurückrechnen.
|
||||
double _vertikaleStrecke = 0;
|
||||
|
||||
void _oeffne(BuildContext context) =>
|
||||
context.read<PlayerExpansionController>().open(context);
|
||||
|
||||
@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,
|
||||
// Trennt die Leiste vom Inhalt darüber, ohne Schatten.
|
||||
border: Border(top: BorderSide(color: MeloTheme.border)),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onVerticalDragStart: (_) => _vertikaleStrecke = 0,
|
||||
onVerticalDragUpdate: (details) {
|
||||
_vertikaleStrecke += details.delta.dy;
|
||||
context.read<PlayerExpansionController>().dragBy(details.delta.dy);
|
||||
},
|
||||
onVerticalDragEnd: (details) => context
|
||||
.read<PlayerExpansionController>()
|
||||
.dragEnd(context, _vertikaleStrecke,
|
||||
details.velocity.pixelsPerSecond.dy,
|
||||
ausVollbild: false),
|
||||
onHorizontalDragUpdate: (details) => setState(() {
|
||||
_ziehtHorizontalGerade = true;
|
||||
_horizontalVersatz += details.delta.dx;
|
||||
}),
|
||||
onHorizontalDragEnd: (details) {
|
||||
final wechsel = titelWechselMitStrecke(
|
||||
_horizontalVersatz, details.velocity.pixelsPerSecond.dx);
|
||||
setState(() {
|
||||
_ziehtHorizontalGerade = false;
|
||||
_horizontalVersatz = 0;
|
||||
});
|
||||
switch (wechsel) {
|
||||
case Wechsel.naechster:
|
||||
handler.skipToNext();
|
||||
case Wechsel.vorheriger:
|
||||
handler.skipToPrevious();
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: () => _oeffne(context),
|
||||
child: AnimatedContainer(
|
||||
duration: _ziehtHorizontalGerade
|
||||
? Duration.zero
|
||||
: MeloMotion.ruhig(context, MeloMotion.normal),
|
||||
curve: MeloMotion.curve,
|
||||
transform: Matrix4.translationValues(_horizontalVersatz, 0, 0),
|
||||
height: MiniPlayer.hoehe,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: MeloSpace.md),
|
||||
// Das Cover rendert WanderndesCover in HomeShell in
|
||||
// einer eigenen Ebene über allem (deckt exakt diese
|
||||
// Position bei progress == 0 ab) — hier nur Platz
|
||||
// freihalten, damit Titel/Bedienung nicht springen.
|
||||
const SizedBox(width: 52),
|
||||
const SizedBox(width: MeloSpace.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_Fortschritt(handler: handler),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Bedienung extends StatelessWidget {
|
||||
const _Bedienung({required this.handler});
|
||||
|
||||
final MeloAudioHandler handler;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<PlaybackState>(
|
||||
stream: handler.playbackState,
|
||||
builder: (context, snap) {
|
||||
final laeuft = snap.data?.playing ?? false;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Vorheriger Titel',
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
onPressed: handler.skipToPrevious,
|
||||
),
|
||||
// Der wichtigste Knopf der Leiste: eingefärbt und größer, damit
|
||||
// er sich ohne Hinsehen treffen lässt.
|
||||
IconButton.filled(
|
||||
tooltip: laeuft ? 'Pause' : 'Abspielen',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: MeloTheme.red,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize:
|
||||
const Size(MeloTheme.minTouchTarget, MeloTheme.minTouchTarget),
|
||||
),
|
||||
icon: Icon(laeuft ? Icons.pause : Icons.play_arrow, size: 26),
|
||||
onPressed: laeuft ? handler.pause : handler.play,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Nächster Titel',
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: handler.skipToNext,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Fortschritt extends StatelessWidget {
|
||||
const _Fortschritt({required this.handler});
|
||||
|
||||
final MeloAudioHandler handler;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<Duration?>(
|
||||
stream: handler.durationStream,
|
||||
builder: (context, dSnap) {
|
||||
final dauer = dSnap.data ?? Duration.zero;
|
||||
return StreamBuilder<Duration>(
|
||||
stream: handler.positionStream,
|
||||
builder: (context, pSnap) {
|
||||
final position = pSnap.data ?? Duration.zero;
|
||||
return LinearProgressIndicator(
|
||||
value: dauer.inMilliseconds > 0
|
||||
? (position.inMilliseconds / dauer.inMilliseconds)
|
||||
.clamp(0.0, 1.0)
|
||||
: 0,
|
||||
minHeight: 2,
|
||||
backgroundColor: MeloTheme.border,
|
||||
valueColor: const AlwaysStoppedAnimation(MeloTheme.red),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
+520
-106
@@ -1,20 +1,31 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show lerpDouble;
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show OverflowBoxFit;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../library/database.dart';
|
||||
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 'player_expansion_controller.dart';
|
||||
import 'queue_screen.dart';
|
||||
|
||||
/// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls.
|
||||
/// Inhalt des Vollbild-Players — wird von HomeShell in ein per progress
|
||||
/// interpoliertes Rechteck gesetzt, ist selbst kein Scaffold/keine Route.
|
||||
class NowPlayingScreen extends StatelessWidget {
|
||||
const NowPlayingScreen({super.key});
|
||||
|
||||
void _showLyrics(BuildContext context, String songId) {
|
||||
if (songId.isEmpty) return;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => _LyricsSheet(songId: songId),
|
||||
@@ -24,89 +35,381 @@ class NowPlayingScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
actions: [
|
||||
StreamBuilder<MediaItem?>(
|
||||
stream: handler.mediaItem,
|
||||
builder: (context, snapshot) {
|
||||
final item = snapshot.data;
|
||||
if (item == null) return const SizedBox.shrink();
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Songtext',
|
||||
icon: const Icon(Icons.lyrics),
|
||||
onPressed: () => _showLyrics(
|
||||
context, item.extras?['songId'] as String? ?? item.id),
|
||||
// Ersetzt, was bisher implizit über Scaffold/AppBar(backgroundColor:
|
||||
// transparent) auf dem durchgehend dunklen Theme lief (siehe
|
||||
// MeloTheme.dark/CLAUDE.md „Dark Theme“) — ohne Scaffold/AppBar muss die
|
||||
// Status-Icon-Helligkeit hier explizit gesetzt werden, sonst bleiben die
|
||||
// Icons je nach Systemzustand zufällig dunkel auf dunklem Grund.
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light,
|
||||
child: _CoverGrund(
|
||||
handler: handler,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_VollbildLeiste(handler: handler, onLyrics: _showLyrics),
|
||||
Expanded(
|
||||
child: _SchliessGeste(
|
||||
child: StreamBuilder<MediaItem?>(
|
||||
stream: handler.mediaItem,
|
||||
builder: (context, snapshot) {
|
||||
final item = snapshot.data;
|
||||
if (item == null) {
|
||||
return const Center(child: Text('Nichts in Wiedergabe'));
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, raum) {
|
||||
final nebeneinander = raum.maxWidth > raum.maxHeight;
|
||||
// Während der Öffnen-Geste ist das interpolierte
|
||||
// Rechteck (main.dart, Rect.lerp Mini-/Vollbild-
|
||||
// Player) oft noch kleiner, als _Angaben an
|
||||
// Mindesthöhe braucht — ohne diese Kapselung meldet
|
||||
// Flutter dann "RenderFlex overflowed" (im Row-Zweig
|
||||
// unten bekäme _Angaben selbst eine zu knappe Höhe
|
||||
// zugeteilt, im Column-Zweig überschreitet Lücke +
|
||||
// _Angaben-Eigenhöhe zusammen die verfügbare Höhe).
|
||||
// ConstrainedBox AUSSEN um OverflowBox deckelt, was
|
||||
// OverflowBox nach außen als eigene Größe meldet,
|
||||
// hart auf das von raum bekannte Maß; OverflowBox
|
||||
// selbst gibt ihrem Kind (_Angaben) über
|
||||
// maxHeight: infinity davon UNABHÄNGIG weiterhin
|
||||
// unbegrenzte Höhe — _Angaben behält so seine
|
||||
// natürliche Größe, der überschüssige Teil wird
|
||||
// vom ClipRect einfach nicht gemalt statt eine
|
||||
// Overflow-Warnung auszulösen. Läge die Deckelung
|
||||
// stattdessen INNEN (zwischen OverflowBox und
|
||||
// _Angaben), bekäme _Angaben selbst zu wenig Höhe
|
||||
// und liefe intern über. Bei ausreichend Platz
|
||||
// (Endzustand progress==1) ist raum.maxHeight groß
|
||||
// genug, dass nichts geklemmt wird — die Optik
|
||||
// bleibt dort unverändert.
|
||||
final inhalt = ClipRect(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: (raum.maxHeight - MeloSpace.lg)
|
||||
.clamp(0.0, double.infinity),
|
||||
),
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.center,
|
||||
maxHeight: double.infinity,
|
||||
fit: OverflowBoxFit.deferToChild,
|
||||
child: _Angaben(item: item, handler: handler),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: nebeneinander
|
||||
? Row(children: [
|
||||
Expanded(child: Center(child: _CoverPlatzhalter())),
|
||||
const SizedBox(width: MeloSpace.lg),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: inhalt,
|
||||
),
|
||||
),
|
||||
),
|
||||
])
|
||||
: Column(children: [
|
||||
Expanded(child: Center(child: _CoverPlatzhalter())),
|
||||
const SizedBox(height: MeloSpace.lg),
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: inhalt,
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
FavoriteButton(songId: item.extras?['songId'] as String? ?? ''),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
_SleepTimerButton(handler: handler),
|
||||
IconButton(
|
||||
tooltip: 'Warteschlange',
|
||||
icon: const Icon(Icons.queue_music),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const QueueScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: StreamBuilder<MediaItem?>(
|
||||
stream: handler.mediaItem,
|
||||
builder: (context, snapshot) {
|
||||
final item = snapshot.data;
|
||||
if (item == null) {
|
||||
return const Center(child: Text('Nichts in Wiedergabe'));
|
||||
}
|
||||
return 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: Colors.white54),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_ProgressBar(handler: handler),
|
||||
const _Controls(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Cover extends StatelessWidget {
|
||||
const _Cover({required this.item});
|
||||
/// Schlüssel des Cover-Platzhalters — Task 6 misst darüber die tatsächliche
|
||||
/// Vollbild-Position/-Größe des Covers (`RenderBox.localToGlobal`), statt
|
||||
/// `NowPlayingScreen`s responsives Layout (Row/Column-Weiche, AspectRatio)
|
||||
/// von außen nachzurechnen.
|
||||
final coverPlatzhalterKey = GlobalKey();
|
||||
|
||||
/// Platzhalter im Layout — das eigentliche Cover rendert `WanderndesCover`
|
||||
/// (Task 6) in einer eigenen Ebene über allem, damit es unabhängig vom
|
||||
/// restlichen Inhalt zwischen Mini- und Vollbild-Rechteck wandern kann.
|
||||
class _CoverPlatzhalter extends StatelessWidget {
|
||||
const _CoverPlatzhalter();
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
AspectRatio(key: coverPlatzhalterKey, aspectRatio: 1, child: const SizedBox());
|
||||
}
|
||||
|
||||
/// Cover-Bild, das zwischen Mini-Player- und Vollbild-Rechteck wandert.
|
||||
///
|
||||
/// Ersetzt die frühere `Hero`-Animation: die feuert nur bei echten
|
||||
/// Navigator-Transitions, und `NowPlayingScreen` ist seit dem Overlay-Umbau
|
||||
/// keine Route mehr. Positionierung/Größe kommen direkt aus [progress],
|
||||
/// live an die Zugstrecke der Wischgeste gekoppelt.
|
||||
class WanderndesCover extends StatelessWidget {
|
||||
const WanderndesCover({
|
||||
super.key,
|
||||
required this.progress,
|
||||
required this.artUri,
|
||||
required this.miniRect,
|
||||
required this.vollbildRect,
|
||||
});
|
||||
|
||||
final double progress;
|
||||
final Uri? artUri;
|
||||
final Rect miniRect;
|
||||
final Rect vollbildRect;
|
||||
|
||||
static const _miniRadius = 6.0;
|
||||
static const _vollbildRadius = 16.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rect = Rect.lerp(miniRect, vollbildRect, progress)!;
|
||||
final radius = lerpDouble(_miniRadius, _vollbildRadius, progress)!;
|
||||
return Positioned.fromRect(
|
||||
rect: rect,
|
||||
child: CoverImage(artUri: artUri, radius: radius),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Schließt den Vollbild-Player per Wischgeste nach unten — symmetrisch zum
|
||||
/// Öffnen am Mini-Player, live über denselben PlayerExpansionController.
|
||||
/// Eigenes StatefulWidget, weil eine lokale Rohstrecke über die Gestendauer
|
||||
/// mitgeführt werden muss (analog zum bisherigen `_versatz`-Muster in
|
||||
/// mini_player.dart).
|
||||
class _SchliessGeste extends StatefulWidget {
|
||||
const _SchliessGeste({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_SchliessGeste> createState() => _SchliessGesteState();
|
||||
}
|
||||
|
||||
class _SchliessGesteState extends State<_SchliessGeste> {
|
||||
double _strecke = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = context.read<PlayerExpansionController>();
|
||||
return GestureDetector(
|
||||
// Ohne opaque behavior fängt die Geste keine Berührungen über den
|
||||
// großflächigen, unbemalten Bereichen des Inhalts ab (z.B. über dem
|
||||
// Cover-Platzhalter, der nur ein leeres SizedBox ist) — GestureDetector
|
||||
// reagiert per Default (deferToChild) nur dort, wo ein Nachfahre
|
||||
// tatsächlich etwas zeichnet (per Testlauf verifiziert).
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragStart: (_) => _strecke = 0,
|
||||
onVerticalDragUpdate: (details) {
|
||||
_strecke += details.delta.dy;
|
||||
controller.dragBy(details.delta.dy);
|
||||
},
|
||||
onVerticalDragEnd: (details) => controller.dragEnd(
|
||||
context, _strecke, details.velocity.pixelsPerSecond.dy,
|
||||
ausVollbild: true),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Die bisherigen AppBar-Actions (Songtext, Sleep-Timer, Warteschlange) als
|
||||
/// eigene Kopfzeile statt echter AppBar — eine Route/Scaffold gibt es hier
|
||||
/// nicht mehr.
|
||||
class _VollbildLeiste extends StatelessWidget {
|
||||
const _VollbildLeiste({required this.handler, required this.onLyrics});
|
||||
final MeloAudioHandler handler;
|
||||
final void Function(BuildContext, String) onLyrics;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
StreamBuilder<MediaItem?>(
|
||||
stream: handler.mediaItem,
|
||||
builder: (context, snapshot) {
|
||||
final item = snapshot.data;
|
||||
if (item == null) return const SizedBox.shrink();
|
||||
final songId = songIdOf(item) ?? navidromeIdOf(item) ?? '';
|
||||
return IconButton(
|
||||
tooltip: songId.isEmpty
|
||||
? 'Kein Songtext für diesen Titel verfügbar'
|
||||
: 'Songtext',
|
||||
icon: const Icon(Icons.lyrics),
|
||||
onPressed: songId.isEmpty ? null : () => onLyrics(context, songId),
|
||||
);
|
||||
},
|
||||
),
|
||||
_SleepTimerButton(handler: handler),
|
||||
IconButton(
|
||||
tooltip: 'Warteschlange',
|
||||
icon: const Icon(Icons.queue_music),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const QueueScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// Ein Titel der Bibliothek wird lokal favorisiert, einer
|
||||
// vom Server am Server — beide bekommen dasselbe Herz,
|
||||
// statt dass eines davon ausgegraut bleibt.
|
||||
_Herz(item: item),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: MeloSpace.sm),
|
||||
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) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: CoverImage(artUri: item.artUri, radius: 16),
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -147,9 +450,9 @@ class _ProgressBar extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(_fmt(position),
|
||||
style: const TextStyle(color: Colors.white54)),
|
||||
style: const TextStyle(color: MeloTheme.text2)),
|
||||
Text(_fmt(total),
|
||||
style: const TextStyle(color: Colors.white54)),
|
||||
style: const TextStyle(color: MeloTheme.text2)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -187,7 +490,7 @@ class _Controls extends StatelessWidget {
|
||||
IconButton(
|
||||
tooltip: 'Zufallswiedergabe',
|
||||
icon: Icon(Icons.shuffle,
|
||||
color: shuffle ? MeloTheme.red : Colors.white54),
|
||||
color: shuffle ? MeloTheme.red : MeloTheme.text2),
|
||||
onPressed: () => handler.setShuffleMode(shuffle
|
||||
? AudioServiceShuffleMode.none
|
||||
: AudioServiceShuffleMode.all),
|
||||
@@ -218,7 +521,7 @@ class _Controls extends StatelessWidget {
|
||||
? Icons.repeat_one
|
||||
: Icons.repeat,
|
||||
color: repeat == AudioServiceRepeatMode.none
|
||||
? Colors.white54
|
||||
? MeloTheme.text2
|
||||
: MeloTheme.red,
|
||||
),
|
||||
onPressed: () => handler.setRepeatMode(switch (repeat) {
|
||||
@@ -268,6 +571,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 +626,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 +637,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 +700,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: Colors.white54)),
|
||||
)
|
||||
: 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../shared/theme.dart';
|
||||
import 'wischen.dart';
|
||||
|
||||
/// Wie viel Zugstrecke (Pixel) einem vollen Öffnen-Fortschritt entspricht —
|
||||
/// rein darstellerisch, unabhängig von der Öffnen/Schließen-Entscheidung in
|
||||
/// [wischen.dart].
|
||||
const oeffnenReferenzHoehe = 300.0;
|
||||
|
||||
/// Optischer Fortschritt zwischen Mini-Player (0) und Vollbild-Player (1).
|
||||
///
|
||||
/// Trennt bewusst zwei Dinge: WANN geöffnet/geschlossen wird (unverändert
|
||||
/// [oeffnetVollbildMitStrecke] aus wischen.dart) und WIE WEIT der Übergang
|
||||
/// gerade optisch ist (dieser Wert, live an die Zugstrecke gekoppelt).
|
||||
class PlayerExpansionController extends ChangeNotifier {
|
||||
PlayerExpansionController({required TickerProvider vsync})
|
||||
: _animation = AnimationController(vsync: vsync) {
|
||||
_animation.addListener(notifyListeners);
|
||||
}
|
||||
|
||||
final AnimationController _animation;
|
||||
|
||||
double get progress => _animation.value;
|
||||
|
||||
/// [dy] ist details.delta.dy einer laufenden Wischgeste, unverändertes
|
||||
/// Vorzeichen. Nach oben (negativ) erhöht progress, nach unten (positiv)
|
||||
/// verringert ihn — dieselbe Formel bedient Öffnen- und Schließen-Geste
|
||||
/// symmetrisch, je nachdem, wo progress gerade steht. Bewusst rekursiv auf
|
||||
/// dem bereits geclampten Wert (nicht auf der aufsummierten Rohstrecke):
|
||||
/// so reagiert eine Richtungsumkehr mitten in der Geste sofort, auch wenn
|
||||
/// die Geste vorher schon über [oeffnenReferenzHoehe] hinausgezogen wurde.
|
||||
void dragBy(double dy) {
|
||||
_animation.value = (_animation.value - dy / oeffnenReferenzHoehe).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// [strecke]/[geschwindigkeit] wie in wischen.dart (negativ = nach oben).
|
||||
/// [ausVollbild] benennt den Aufrufer-Kontext explizit, statt ihn aus dem
|
||||
/// Vorzeichen von [strecke] zu raten (früherer Bug: bei bereits offenem
|
||||
/// Player klappte ein schwaches, versehentliches Aufwärtszittern — negative
|
||||
/// Strecke, weit unter der Schwelle — den Player zu, weil das Vorzeichen
|
||||
/// allein in den ÖFFNEN-Zweig führte, der bei so einer schwachen Geste
|
||||
/// `false` liefert). `false` (Mini-Player, Öffnen-Geste) entscheidet direkt
|
||||
/// über [oeffnetVollbildMitStrecke]; `true` (Vollbild-Player, Schließen-
|
||||
/// Geste) über dieselbe Funktion mit gespiegeltem Vorzeichen — "stark genug
|
||||
/// nach unten" ist spiegelbildlich zu "stark genug nach oben". Fliegt
|
||||
/// danach mit der echten Loslass-Geschwindigkeit als Startimpuls zum
|
||||
/// entschiedenen Ziel.
|
||||
void dragEnd(BuildContext context, double strecke, double geschwindigkeit,
|
||||
{required bool ausVollbild}) {
|
||||
final zielIstOffen = ausVollbild
|
||||
? !oeffnetVollbildMitStrecke(-strecke, -geschwindigkeit)
|
||||
: oeffnetVollbildMitStrecke(strecke, geschwindigkeit);
|
||||
if (MediaQuery.disableAnimationsOf(context)) {
|
||||
_animation.value = zielIstOffen ? 1.0 : 0.0;
|
||||
return;
|
||||
}
|
||||
// -geschwindigkeit/oeffnenReferenzHoehe, weil dragBy() ein negatives dy
|
||||
// (nach oben) in steigenden progress übersetzt — dieselbe Umrechnung
|
||||
// rückwärts. Zeigt die tatsächliche Geschwindigkeit nicht zum
|
||||
// entschiedenen Ziel (z.B. ein Aufwärtswisch, der am Ende minimal
|
||||
// zurückfedert), erzwingt ein moderater Mindestimpuls die Zielrichtung,
|
||||
// statt fling() mit widersprüchlichem Vorzeichen zu verwirren.
|
||||
final rohimpuls = -geschwindigkeit / oeffnenReferenzHoehe;
|
||||
final zeigtZumZiel = zielIstOffen ? rohimpuls > 0 : rohimpuls < 0;
|
||||
final impuls = zeigtZumZiel ? rohimpuls : (zielIstOffen ? 1.0 : -1.0);
|
||||
_animation.fling(velocity: impuls);
|
||||
}
|
||||
|
||||
void open(BuildContext context) => _animiereZu(context, 1.0);
|
||||
|
||||
void close(BuildContext context) => _animiereZu(context, 0.0);
|
||||
|
||||
/// Für Tap-Auslöser (kein Ziehen, keine Loslass-Geschwindigkeit) — anders
|
||||
/// als [dragEnd] mit fester Kurve/Dauer aus [MeloMotion], nicht als Fling.
|
||||
void _animiereZu(BuildContext context, double ziel) {
|
||||
if (MediaQuery.disableAnimationsOf(context)) {
|
||||
_animation.value = ziel;
|
||||
return;
|
||||
}
|
||||
_animation.animateTo(ziel, duration: MeloMotion.normal, curve: MeloMotion.curve);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animation.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
@@ -27,7 +28,7 @@ class QueueScreen extends StatelessWidget {
|
||||
if (queue.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Warteschlange ist leer',
|
||||
style: TextStyle(color: Colors.white54)),
|
||||
style: TextStyle(color: MeloTheme.text2)),
|
||||
);
|
||||
}
|
||||
return StreamBuilder<MediaItem?>(
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -6,8 +7,11 @@ import '../library/database.dart';
|
||||
import '../library/playlist_service.dart';
|
||||
import '../library/song_media.dart';
|
||||
import '../player/audio_handler.dart';
|
||||
import '../player/player_expansion_controller.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.
|
||||
class PlaylistDetailScreen extends StatelessWidget {
|
||||
@@ -17,10 +21,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>>(
|
||||
@@ -29,14 +30,14 @@ class PlaylistDetailScreen extends StatelessWidget {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text('Fehler: ${snapshot.error}',
|
||||
style: const TextStyle(color: Colors.white54)),
|
||||
style: const TextStyle(color: MeloTheme.text2)),
|
||||
);
|
||||
}
|
||||
final songs = snapshot.data ?? const [];
|
||||
if (songs.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Playlist ist leer — füge Songs aus der Bibliothek hinzu',
|
||||
style: TextStyle(color: Colors.white54)),
|
||||
style: TextStyle(color: MeloTheme.text2)),
|
||||
);
|
||||
}
|
||||
return ReorderableListView.builder(
|
||||
@@ -47,49 +48,108 @@ 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 (!context.mounted) return;
|
||||
context.read<PlayerExpansionController>().open(context);
|
||||
if (uebersprungen > 0) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(offlineHinweis(uebersprungen))),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -42,7 +43,7 @@ class PlaylistsScreen extends StatelessWidget {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text('Fehler: ${snapshot.error}',
|
||||
style: const TextStyle(color: Colors.white54)),
|
||||
style: const TextStyle(color: MeloTheme.text2)),
|
||||
);
|
||||
}
|
||||
final playlists = snapshot.data ?? const [];
|
||||
@@ -135,10 +136,10 @@ class _Empty extends StatelessWidget {
|
||||
children: [
|
||||
Icon(Icons.playlist_play, size: 64, color: Colors.white24),
|
||||
SizedBox(height: 12),
|
||||
Text('Noch keine Playlisten', style: TextStyle(color: Colors.white54)),
|
||||
Text('Noch keine Playlisten', style: TextStyle(color: MeloTheme.text2)),
|
||||
SizedBox(height: 8),
|
||||
Text('Tippe auf + um eine Playlist zu erstellen',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 12)),
|
||||
style: TextStyle(color: MeloTheme.text3, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -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: Colors.white54)),
|
||||
);
|
||||
}
|
||||
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)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
@@ -45,10 +47,26 @@ class BakaAuth extends ChangeNotifier {
|
||||
|
||||
String? _token;
|
||||
String _benutzer = '';
|
||||
int? _verbleibend;
|
||||
|
||||
/// Laufender Login-Versuch aus [autoAnmelden] — solange er offen ist,
|
||||
/// hängt sich ein überlappender Aufruf daran statt einen zweiten
|
||||
/// Request loszuschicken (Race Condition: mehrere Tabs im IndexedStack
|
||||
/// lösen beim App-Start gleichzeitig autoAnmelden aus).
|
||||
Future<void>? _laufenderAutoLogin;
|
||||
|
||||
/// Fehlertext des letzten automatischen Anmeldeversuchs — für Diagnose,
|
||||
/// da autoAnmelden() (anders als anmelden()) keinen Rückgabewert hat.
|
||||
String? letzterAutoLoginFehler;
|
||||
|
||||
bool get istAngemeldet => _token != null && _token!.isNotEmpty;
|
||||
String get benutzer => _benutzer;
|
||||
|
||||
/// Zuletzt vom Server gemeldeter Kontingent-Stand (100/Tag) — nur fürs
|
||||
/// Anzeigen, die Wahrheit bleibt beim Server. `null` vor dem ersten
|
||||
/// Download in dieser Session.
|
||||
int? get verbleibend => _verbleibend;
|
||||
|
||||
/// Header für Anfragen an den Proxy. Ohne Anmeldung ohne `Authorization`.
|
||||
Map<String, String> get authHeader => {
|
||||
if (istAngemeldet) 'Authorization': 'Bearer $_token',
|
||||
@@ -100,11 +118,54 @@ 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;
|
||||
_laufenderAutoLogin ??= _autoAnmeldenIntern(benutzer, passwort)
|
||||
.whenComplete(() => _laufenderAutoLogin = null);
|
||||
await _laufenderAutoLogin;
|
||||
}
|
||||
|
||||
Future<void> _autoAnmeldenIntern(String benutzer, String passwort) async {
|
||||
final fehler = await anmelden(benutzer, passwort);
|
||||
letzterAutoLoginFehler = fehler;
|
||||
if (fehler != null) {
|
||||
debugPrint('Auto-Anmeldung fehlgeschlagen: $fehler');
|
||||
}
|
||||
}
|
||||
|
||||
/// Übernimmt den zuletzt vom Server gemeldeten Kontingent-Stand.
|
||||
void merkeVerbleibend(int n) {
|
||||
_verbleibend = n;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> abmelden() async {
|
||||
_token = null;
|
||||
_benutzer = '';
|
||||
_verbleibend = null;
|
||||
await _speicher.loeschen(_tokenKey);
|
||||
await _speicher.loeschen(_userKey);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'baka_auth.dart' show TokenSpeicher, SicherenSpeicher;
|
||||
import 'yt_download_service.dart';
|
||||
|
||||
/// Anonymer Gast-Zugang zum YouTube-Proxy: kein Passwort, aber mit
|
||||
/// serverseitig durchgesetztem Tages-Limit (der Proxy zählt und meldet den
|
||||
/// Stand über `guest_remaining` in der yt-dl-Antwort zurück). Ergänzt
|
||||
/// [BakaAuth]/`baka_auth.dart`, ersetzt es nicht.
|
||||
class GastZugang extends ChangeNotifier {
|
||||
GastZugang({http.Client? client, TokenSpeicher? speicher})
|
||||
: _client = client ?? http.Client(),
|
||||
_speicher = speicher ?? const SicherenSpeicher();
|
||||
|
||||
static const proxyUrl = YtDownloadService.proxyUrl;
|
||||
static const _tokenKey = 'guest_token';
|
||||
|
||||
final http.Client _client;
|
||||
final TokenSpeicher _speicher;
|
||||
|
||||
String? _token;
|
||||
int? _verbleibend;
|
||||
|
||||
bool get hatToken => _token != null && _token!.isNotEmpty;
|
||||
|
||||
/// Zuletzt vom Server gemeldeter Kontingent-Stand — nur fürs Anzeigen,
|
||||
/// die Wahrheit bleibt beim Server. `null` vor dem ersten Download.
|
||||
int? get verbleibend => _verbleibend;
|
||||
|
||||
/// Header für Anfragen an den Proxy. Ohne Token leer.
|
||||
Map<String, String> get gastHeader =>
|
||||
{if (hatToken) 'X-Guest-Token': _token!};
|
||||
|
||||
/// Stellt einen früher geholten Gast-Token wieder her (App-Start).
|
||||
Future<void> laden() async {
|
||||
_token = await _speicher.lesen(_tokenKey);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Holt einen neuen Gast-Token vom Proxy. Gibt `null` bei Erfolg zurück,
|
||||
/// sonst den Fehlertext.
|
||||
Future<String?> holeToken() async {
|
||||
final http.Response antwort;
|
||||
try {
|
||||
antwort = await _client
|
||||
.post(Uri.parse('$proxyUrl/api/guest-token'))
|
||||
.timeout(const Duration(seconds: 15));
|
||||
} catch (e) {
|
||||
debugPrint('Gast-Token nicht erreichbar: $e');
|
||||
return 'Proxy nicht erreichbar';
|
||||
}
|
||||
|
||||
if (antwort.statusCode != 200) {
|
||||
return 'Gast-Zugang fehlgeschlagen (${antwort.statusCode})';
|
||||
}
|
||||
|
||||
Map<String, dynamic> daten;
|
||||
try {
|
||||
daten = jsonDecode(antwort.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return 'Proxy antwortet unverständlich (${antwort.statusCode})';
|
||||
}
|
||||
|
||||
final token = daten['guest_token'] as String?;
|
||||
if (token == null || token.isEmpty) {
|
||||
return 'Proxy hat keinen Gast-Token geliefert';
|
||||
}
|
||||
_token = token;
|
||||
await _speicher.schreiben(_tokenKey, token);
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Übernimmt den zuletzt vom Server gemeldeten Kontingent-Stand.
|
||||
void merkeVerbleibend(int n) {
|
||||
_verbleibend = n;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Verwirft den Gast-Zugang wieder — nötig, damit ein Gast doch noch zum
|
||||
/// Baka-Konto wechseln kann. Ohne das wäre `_AnmeldeDialog` (der einzige
|
||||
/// Einstieg in der App für Baka-Login, auch für den Melo-Cloud-Sync)
|
||||
/// dauerhaft unerreichbar, sobald einmal ein Gast-Token besteht.
|
||||
Future<void> verwerfen() async {
|
||||
_token = null;
|
||||
_verbleibend = null;
|
||||
await _speicher.loeschen(_tokenKey);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,13 @@ 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;
|
||||
String get serverUrl => _serverUrl;
|
||||
|
||||
/// 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 +239,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 +257,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 +338,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 +407,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 +426,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 {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -5,11 +6,18 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'baka_auth.dart';
|
||||
import 'gast_zugang.dart';
|
||||
|
||||
/// Entfernt alles, was in einem Dateinamen Ärger macht.
|
||||
String sichererDateiname(String titel) =>
|
||||
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({
|
||||
@@ -28,18 +36,24 @@ class YtErgebnis {
|
||||
/// Lädt Musik über den Baka-YouTube-Proxy herunter. Der Proxy erledigt die
|
||||
/// eigentliche Arbeit und liefert eine fertige MP3; die App speichert sie nur.
|
||||
class YtDownloadService extends ChangeNotifier {
|
||||
YtDownloadService({required this.auth, http.Client? client})
|
||||
YtDownloadService({required this.auth, this.gast, http.Client? client})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
static const proxyUrl = 'https://yt.baka-net.de';
|
||||
|
||||
final BakaAuth auth;
|
||||
final GastZugang? gast;
|
||||
final http.Client _client;
|
||||
|
||||
bool _laeuft = false;
|
||||
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 +72,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,9 +94,11 @@ class YtDownloadService extends ChangeNotifier {
|
||||
{required String zielOrdner, bool cookies = true}) async {
|
||||
_fehler = null;
|
||||
_laeuft = true;
|
||||
final abbruch = _abbruch = Completer<void>();
|
||||
notifyListeners();
|
||||
|
||||
if (!auth.istAngemeldet) {
|
||||
final gastAktiv = !auth.istAngemeldet && (gast?.hatToken ?? false);
|
||||
if (!auth.istAngemeldet && !gastAktiv) {
|
||||
_scheitere('Bitte zuerst beim Baka-Konto anmelden');
|
||||
return null;
|
||||
}
|
||||
@@ -76,18 +106,34 @@ class YtDownloadService extends ChangeNotifier {
|
||||
_scheitere('Das ist keine YouTube-Adresse');
|
||||
return null;
|
||||
}
|
||||
final zugangsHeader = gastAktiv ? gast!.gastHeader : auth.authHeader;
|
||||
|
||||
// ─── 1/2: Proxy fragen — er lädt das Video und wandelt es um ───
|
||||
_melde('Proxy wird gefragt …');
|
||||
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: {
|
||||
...zugangsHeader,
|
||||
'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');
|
||||
@@ -95,7 +141,10 @@ class YtDownloadService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
if (antwort.statusCode == 401) {
|
||||
_scheitere('Anmeldung abgelaufen — bitte neu anmelden');
|
||||
if (gastAktiv) unawaited(gast!.holeToken());
|
||||
_scheitere(gastAktiv
|
||||
? 'Gast-Zugang abgelaufen — bitte erneut versuchen'
|
||||
: 'Anmeldung abgelaufen — bitte neu anmelden');
|
||||
return null;
|
||||
}
|
||||
if (antwort.statusCode != 200) {
|
||||
@@ -107,6 +156,14 @@ class YtDownloadService extends ChangeNotifier {
|
||||
final titel = daten['titel'] as String? ?? 'Unbekannt';
|
||||
final dauer = (daten['dauer'] as int?) ?? 0;
|
||||
final mp3Url = daten['mp3_url'] as String? ?? '/api/dl/audio.mp3';
|
||||
final gastRest = daten['guest_remaining'] as int?;
|
||||
if (gastAktiv && gastRest != null) {
|
||||
gast!.merkeVerbleibend(gastRest);
|
||||
}
|
||||
final cloudRest = daten['cloud_remaining'] as int?;
|
||||
if (!gastAktiv && cloudRest != null) {
|
||||
auth.merkeVerbleibend(cloudRest);
|
||||
}
|
||||
|
||||
// ─── 2/2: fertige MP3 abholen und speichern ───
|
||||
_melde('Lade „$titel“ …');
|
||||
@@ -117,14 +174,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: zugangsHeader)
|
||||
.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
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'baka_auth.dart';
|
||||
import 'gast_zugang.dart';
|
||||
import 'yt_download_service.dart';
|
||||
|
||||
/// Ein Treffer der YouTube-Suche über den Baka-Proxy.
|
||||
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, this.gast, http.Client? client})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
static const proxyUrl = YtDownloadService.proxyUrl;
|
||||
|
||||
final BakaAuth auth;
|
||||
final GastZugang? gast;
|
||||
final http.Client _client;
|
||||
|
||||
bool _laeuft = false;
|
||||
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;
|
||||
|
||||
final gastAktiv = !auth.istAngemeldet && (gast?.hatToken ?? false);
|
||||
if (!auth.istAngemeldet && !gastAktiv) {
|
||||
_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: gastAktiv ? gast!.gastHeader : 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) {
|
||||
if (gastAktiv) unawaited(gast!.holeToken());
|
||||
_scheitere(gastAktiv
|
||||
? 'Gast-Zugang abgelaufen — bitte erneut versuchen'
|
||||
: '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})';
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@ class EqualizerScreen extends StatelessWidget {
|
||||
padding: EdgeInsets.only(top: 4, bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.headphones, size: 14, color: Colors.white38),
|
||||
Icon(Icons.headphones, size: 14, color: MeloTheme.text3),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Kopfhörer aufsetzen, bevor du die Soundeffekte '
|
||||
'einstellst',
|
||||
style: TextStyle(
|
||||
color: Colors.white38, fontSize: 12),
|
||||
color: MeloTheme.text3, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -86,7 +86,7 @@ class _NotAvailable extends StatelessWidget {
|
||||
const Text(
|
||||
'Der Equalizer ist erst verfügbar, wenn Musik läuft',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white54),
|
||||
style: TextStyle(color: MeloTheme.text2),
|
||||
),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
@@ -176,10 +176,10 @@ class _Bands extends StatelessWidget {
|
||||
),
|
||||
Text(band.label,
|
||||
style:
|
||||
const TextStyle(color: Colors.white54, fontSize: 11)),
|
||||
const TextStyle(color: MeloTheme.text2, fontSize: 11)),
|
||||
Text('${(band.level / 100).round()}',
|
||||
style:
|
||||
const TextStyle(color: Colors.white38, fontSize: 11)),
|
||||
const TextStyle(color: MeloTheme.text3, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -249,9 +249,9 @@ class Knob extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(label,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12)),
|
||||
style: const TextStyle(color: MeloTheme.text2, fontSize: 12)),
|
||||
Text('${(value * 100).round()}',
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 11)),
|
||||
style: const TextStyle(color: MeloTheme.text3, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -294,7 +294,7 @@ class _KnobPainter extends CustomPainter {
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1
|
||||
..color = Colors.white12,
|
||||
..color = MeloTheme.border,
|
||||
);
|
||||
|
||||
final pointer = _start + _sweep * value;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
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 +35,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 +75,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'),
|
||||
@@ -92,16 +105,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
enabled: !lib.scanning,
|
||||
onTap: lib.pickFolderAndScan,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh),
|
||||
title: const Text('Erneut scannen'),
|
||||
subtitle: lib.scanning
|
||||
? Text('Scanne … ${lib.scanDone}'
|
||||
'${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}')
|
||||
: const Text('Bekannte Ordner neu einlesen'),
|
||||
enabled: !lib.scanning,
|
||||
onTap: lib.rescan,
|
||||
),
|
||||
if (zeigeErneutScannenButton(Platform.isAndroid))
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh),
|
||||
title: const Text('Erneut scannen'),
|
||||
subtitle: lib.scanning
|
||||
? Text('Scanne … ${lib.scanDone}'
|
||||
'${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}')
|
||||
: const Text('Bekannte Ordner neu einlesen'),
|
||||
enabled: !lib.scanning,
|
||||
onTap: lib.rescan,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -132,7 +146,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 +163,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(
|
||||
@@ -163,10 +194,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
const _SectionLabel('Musikserver'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cloud_circle),
|
||||
title: const Text('🌐 Navidrome'),
|
||||
subtitle: _navidrome.istVerbunden
|
||||
? const Text('✅ Verbunden')
|
||||
: const Text('Nicht verbunden'),
|
||||
title: const Text('Navidrome'),
|
||||
// "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),
|
||||
@@ -182,13 +215,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'💡 Tipp: Oder nutze Melo lokal ohne Server-Konto (nur deine Musik auf dem Gerät)',
|
||||
style: TextStyle(fontSize: 12, color: Colors.white54),
|
||||
style: TextStyle(fontSize: 12, color: MeloTheme.text2),
|
||||
),
|
||||
),
|
||||
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,21 +232,9 @@ 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'),
|
||||
title: const Text('Favoriten-Sync'),
|
||||
subtitle: const Text('Favoriten vom Server importieren'),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Vom Server laden',
|
||||
@@ -222,7 +244,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.playlist_play),
|
||||
title: const Text('📋 Playlisten-Sync'),
|
||||
title: const Text('Playlisten-Sync'),
|
||||
subtitle: const Text('Server-Playlisten importieren'),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Vom Server laden',
|
||||
@@ -230,25 +252,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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -258,7 +346,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
void _zeigeNavidromeDialog() {
|
||||
final urlCtrl = TextEditingController(text: 'https://musik.baka-net.de');
|
||||
final passCtrl = TextEditingController();
|
||||
String selectedUser = 'Baka';
|
||||
// Vorauswahl: der zuletzt bekannte Navidrome-Benutzer, falls er einer
|
||||
// der drei Server-User ist — sonst Fallback "Baka".
|
||||
const bekannteBenutzer = ['Baka', 'Tinker', 'Dustin'];
|
||||
String selectedUser = bekannteBenutzer.contains(_navidrome.benutzer)
|
||||
? _navidrome.benutzer
|
||||
: 'Baka';
|
||||
bool verbindet = false;
|
||||
bool obscure = true;
|
||||
String? fehler;
|
||||
@@ -267,9 +360,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
backgroundColor: Colors.grey.shade900,
|
||||
title: const Text('🌐 Navidrome Login',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16)),
|
||||
backgroundColor: MeloTheme.surface,
|
||||
title: const Text('Navidrome-Anmeldung',
|
||||
style: TextStyle(color: MeloTheme.text1, fontSize: 16)),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -284,7 +377,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Benutzer:', style: TextStyle(fontSize: 12, color: Colors.white70)),
|
||||
const Text('Benutzer:', style: TextStyle(fontSize: 12, color: MeloTheme.text2)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
@@ -293,8 +386,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 +398,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 +410,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 +450,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.redAccent),
|
||||
strokeWidth: 2, color: MeloTheme.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -400,7 +493,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
},
|
||||
child: const Text('Login',
|
||||
style: TextStyle(color: Colors.redAccent)),
|
||||
style: TextStyle(color: MeloTheme.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -412,11 +505,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 +610,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);
|
||||
@@ -489,7 +637,7 @@ class _SectionLabel extends StatelessWidget {
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54,
|
||||
color: MeloTheme.text2,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -513,7 +661,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'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 A–Z–#-Leiste am rechten Rand. Tippen und Ziehen springt zur
|
||||
/// ersten Stelle des Buchstabens.
|
||||
///
|
||||
/// Zeigt **nur** die Buchstaben, unter denen wirklich etwas steht: eine volle
|
||||
/// A–Z-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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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 '../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,
|
||||
});
|
||||
|
||||
final int anzahl;
|
||||
final VoidCallback onAbbrechen;
|
||||
final VoidCallback onWiedergabeliste;
|
||||
final VoidCallback onWarteschlange;
|
||||
|
||||
@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),
|
||||
),
|
||||
),
|
||||
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'),
|
||||
));
|
||||
}
|
||||
+55
-13
@@ -2,11 +2,24 @@ 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, required this.artUri, this.size, this.radius = 8});
|
||||
const CoverImage({
|
||||
super.key,
|
||||
required this.artUri,
|
||||
this.size,
|
||||
this.radius = MeloRadius.element,
|
||||
});
|
||||
final Uri? artUri;
|
||||
final double? size;
|
||||
final double radius;
|
||||
@@ -16,7 +29,13 @@ class CoverImage extends StatelessWidget {
|
||||
final placeholder = Container(
|
||||
width: size,
|
||||
height: size,
|
||||
color: MeloTheme.surface,
|
||||
decoration: BoxDecoration(
|
||||
color: MeloTheme.surface,
|
||||
// Haarlinie statt Schatten: auf Schwarz trennt ein Umriss die
|
||||
// Fläche sauber, ein Schatten wäre unsichtbar.
|
||||
border: radius > 0 ? MeloTheme.hairline : null,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (_, c) {
|
||||
final s = size ?? (c.hasBoundedWidth ? c.maxWidth : 48.0);
|
||||
@@ -24,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'theme.dart';
|
||||
|
||||
/// Blendet [child] beim ersten Erscheinen leicht gestaffelt ein — statt
|
||||
/// abrupt aufzupoppen, faded und rutscht es ein kleines Stück von unten
|
||||
/// heran. Je weiter hinten in der Liste, desto später beginnt es, damit die
|
||||
/// Liste als Ganzes fließt statt gleichzeitig aufzublitzen.
|
||||
///
|
||||
/// Läuft genau einmal je Element-Lebenszeit: ein Rebuild (etwa weil sich der
|
||||
/// Song-Titel ändert) startet die Animation nicht erneut.
|
||||
class EinblendItem extends StatefulWidget {
|
||||
const EinblendItem({super.key, required this.child, required this.index});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Position in der Liste — bestimmt die Verzögerung vor dem Start.
|
||||
final int index;
|
||||
|
||||
@override
|
||||
State<EinblendItem> createState() => _EinblendItemState();
|
||||
}
|
||||
|
||||
class _EinblendItemState extends State<EinblendItem>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// Ab Index 12 keine zusätzliche Verzögerung mehr — sonst wirkte eine
|
||||
/// lange Liste beim ersten Erscheinen träge, weil die letzten Zeilen erst
|
||||
/// spät anfingen.
|
||||
static const _deckel = 12;
|
||||
static const _versatzProItem = Duration(milliseconds: 25);
|
||||
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: MeloMotion.normal,
|
||||
);
|
||||
late final Animation<double> _kurve = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: MeloMotion.curve,
|
||||
);
|
||||
|
||||
bool _gestartet = false;
|
||||
|
||||
// Kein Start in initState: die Entscheidung hängt an MediaQuery
|
||||
// (Reduce-Motion), und darauf darf man dort noch nicht zugreifen — siehe
|
||||
// LaufBalken. didChangeDependencies läuft unmittelbar danach und ein
|
||||
// _gestartet-Merker verhindert, dass ein späterer Aufruf (z. B. durch
|
||||
// eine geänderte Textgröße) die Animation erneut anstößt.
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_gestartet) return;
|
||||
_gestartet = true;
|
||||
|
||||
if (MeloMotion.ruhig(context, MeloMotion.normal) == Duration.zero) {
|
||||
_controller.value = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
final verzoegerung = _versatzProItem * math.min(widget.index, _deckel);
|
||||
if (verzoegerung == Duration.zero) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
Future.delayed(verzoegerung, () {
|
||||
if (mounted) _controller.forward();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _kurve,
|
||||
builder: (context, child) => Opacity(
|
||||
opacity: _kurve.value,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, MeloSpace.sm * (1 - _kurve.value)),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>(
|
||||
@@ -21,7 +33,7 @@ class FavoriteButton extends StatelessWidget {
|
||||
tooltip: isFavorite ? 'Favorit entfernen' : 'Zu Favoriten hinzufügen',
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: isFavorite ? MeloTheme.red : Colors.white54,
|
||||
color: isFavorite ? MeloTheme.red : MeloTheme.text2,
|
||||
),
|
||||
onPressed: () => service.toggleFavorite(songId),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -38,6 +41,44 @@ 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();
|
||||
}
|
||||
|
||||
@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 +95,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 +128,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 Z–A 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 +180,24 @@ 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)),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 8, 4),
|
||||
child: Row(
|
||||
@@ -140,9 +247,10 @@ class _SortableSongListState extends State<SortableSongList> {
|
||||
Expanded(
|
||||
child: sorted.isEmpty
|
||||
? (widget.empty ?? const SizedBox.shrink())
|
||||
: SongList(sorted),
|
||||
: _liste(sorted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+94
-20
@@ -1,13 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'theme.dart';
|
||||
|
||||
/// Unterreiter im Stil der Referenz-UI: der aktive Reiter ist eine helle
|
||||
/// Pille, die übrigen sind nur Text.
|
||||
///
|
||||
/// Die Pille wechselt weich statt zu springen — so ist sichtbar, dass es
|
||||
/// derselbe Reiter ist, der sich bewegt, und nicht ein neuer Bildschirm.
|
||||
class SubTabs extends StatelessWidget {
|
||||
const SubTabs({
|
||||
super.key,
|
||||
required this.labels,
|
||||
required this.index,
|
||||
required this.onChanged,
|
||||
this.badges,
|
||||
});
|
||||
|
||||
final List<String> labels;
|
||||
@@ -15,34 +21,26 @@ 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(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
MeloSpace.md, MeloSpace.sm, MeloSpace.md, MeloSpace.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < labels.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: GestureDetector(
|
||||
padding: const EdgeInsets.only(right: MeloSpace.sm),
|
||||
child: _Reiter(
|
||||
label: labels[i],
|
||||
aktiv: i == index,
|
||||
badge: badges != null && i < badges!.length && badges![i],
|
||||
onTap: () => onChanged(i),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 18, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: i == index ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
labels[i],
|
||||
style: TextStyle(
|
||||
color: i == index ? Colors.black : Colors.white70,
|
||||
fontSize: 15,
|
||||
fontWeight:
|
||||
i == index ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -50,3 +48,79 @@ class SubTabs extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Reiter extends StatelessWidget {
|
||||
const _Reiter({
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: aktiv,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(MeloRadius.pill),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
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).
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: MeloTheme.minTouchTarget,
|
||||
minWidth: MeloTheme.minTouchTarget,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: MeloSpace.md),
|
||||
decoration: BoxDecoration(
|
||||
color: aktiv ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(MeloRadius.pill),
|
||||
// Der inaktive Reiter bleibt als Fläche erkennbar, ohne sich
|
||||
// vorzudrängen — sonst schweben die Wörter im Nichts.
|
||||
border: aktiv ? null : MeloTheme.hairline,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+193
-1
@@ -1,5 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Abstände. Alles Sichtbare rastet auf 8 ein (4 nur für Feinheiten
|
||||
/// innerhalb eines Elements) — daher wirken Listen und Karten ruhig,
|
||||
/// obwohl nirgends eine Zahl von Hand gewählt wurde.
|
||||
abstract final class MeloSpace {
|
||||
/// 4 — innerhalb eines Elements (Icon zu Text).
|
||||
static const double xs = 4;
|
||||
|
||||
/// 8 — zwischen eng zusammengehörenden Elementen.
|
||||
static const double sm = 8;
|
||||
|
||||
/// 16 — Standard: Seitenrand, Innenabstand einer Karte.
|
||||
static const double md = 16;
|
||||
|
||||
/// 24 — zwischen Abschnitten.
|
||||
static const double lg = 24;
|
||||
|
||||
/// 32 — vor einer neuen Überschrift.
|
||||
static const double xl = 32;
|
||||
}
|
||||
|
||||
/// Eckenradien. Je größer die Fläche, desto größer die Rundung — sonst
|
||||
/// wirken große Karten kantig und kleine Chips aufgeblasen.
|
||||
abstract final class MeloRadius {
|
||||
/// 8 — Coverbilder, kleine Flächen.
|
||||
static const double element = 8;
|
||||
|
||||
/// 12 — Karten.
|
||||
static const double card = 12;
|
||||
|
||||
/// 20 — Sheets von unten.
|
||||
static const double sheet = 20;
|
||||
|
||||
/// Pille — Reiter und Chips.
|
||||
static const double pill = 999;
|
||||
}
|
||||
|
||||
/// Bewegung. Ruhig und kurz: Übergänge sollen die Verbindung zwischen zwei
|
||||
/// Zuständen zeigen, nicht auf sich aufmerksam machen.
|
||||
abstract final class MeloMotion {
|
||||
/// 120 ms — Reaktion auf eine Berührung (Farbe, Skalierung).
|
||||
static const Duration fast = Duration(milliseconds: 120);
|
||||
|
||||
/// 220 ms — Wechsel eines Inhalts.
|
||||
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.
|
||||
class MeloTheme {
|
||||
static const Color red = Color(0xFFC0392B);
|
||||
@@ -9,6 +68,81 @@ class MeloTheme {
|
||||
/// Etwas hellere Fläche für Karten und Chips auf [surface].
|
||||
static const Color surfaceHigh = Color(0xFF1F1F29);
|
||||
|
||||
// === Textfarben ===
|
||||
// Die Werte sind nicht geschätzt: jede erfüllt auf der hellsten Fläche
|
||||
// ([surfaceHigh]) mindestens 4,5:1 nach WCAG. Siehe theme_kontrast_test.dart,
|
||||
// der die Verhältnisse nachrechnet.
|
||||
|
||||
/// Titel und alles, was zuerst gelesen wird.
|
||||
static const Color text1 = Colors.white;
|
||||
|
||||
/// Zweite Ebene: Künstler, Untertitel.
|
||||
static const Color text2 = Color(0xB8FFFFFF); // 72 %
|
||||
|
||||
/// Dritte Ebene: Anzahl, Zeitangabe, Hinweis. Untere Grenze für Text —
|
||||
/// darunter ist der Kontrast nicht mehr ausreichend.
|
||||
static const Color text3 = Color(0x80FFFFFF); // 50 %
|
||||
|
||||
/// Trennlinien und Umrisse. Kein Text — hier gilt 4,5:1 nicht.
|
||||
static const Color border = Color(0x14FFFFFF); // 8 %
|
||||
|
||||
/// Die kleinste Fläche, die sich noch sicher treffen lässt (Material 3).
|
||||
static const double minTouchTarget = 48;
|
||||
|
||||
/// Umriss statt Schlagschatten: auf Schwarz trennt eine Haarlinie
|
||||
/// 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 ("Favoriten", "Download", "Suchen").
|
||||
// Etwas enger gesetzt, damit die Zeile als ein Wortbild steht.
|
||||
displaySmall: TextStyle(
|
||||
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,
|
||||
letterSpacing: -0.3,
|
||||
color: text1),
|
||||
// Hervorgehobene Zeile ("Shuffle-Wiedergabe").
|
||||
titleMedium: TextStyle(
|
||||
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, letterSpacing: 0, color: text2),
|
||||
// Beiwerk: Anzahl, Dauer.
|
||||
bodySmall: TextStyle(fontSize: 12, height: 1.3, color: text3),
|
||||
// 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.6,
|
||||
color: text3),
|
||||
);
|
||||
|
||||
static ThemeData get dark {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: red,
|
||||
@@ -16,22 +150,80 @@ class MeloTheme {
|
||||
).copyWith(
|
||||
primary: red,
|
||||
surface: black,
|
||||
outline: border,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: black,
|
||||
cardColor: surface,
|
||||
textTheme: _textTheme,
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
appBarTheme: const AppBarTheme(
|
||||
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,
|
||||
letterSpacing: -0.3,
|
||||
color: text1),
|
||||
),
|
||||
listTileTheme: const ListTileThemeData(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: MeloSpace.md),
|
||||
minVerticalPadding: MeloSpace.sm,
|
||||
iconColor: text2,
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: border,
|
||||
space: 1,
|
||||
thickness: 1,
|
||||
),
|
||||
iconTheme: const IconThemeData(color: text2),
|
||||
sliderTheme: const SliderThemeData(
|
||||
activeTrackColor: red,
|
||||
thumbColor: red,
|
||||
inactiveTrackColor: border,
|
||||
trackHeight: 3,
|
||||
),
|
||||
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||
color: red,
|
||||
linearTrackColor: border,
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: surfaceHigh,
|
||||
contentTextStyle: const TextStyle(color: text1, fontSize: 14),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(MeloRadius.element),
|
||||
),
|
||||
),
|
||||
bottomSheetTheme: const BottomSheetThemeData(
|
||||
backgroundColor: surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
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: Colors.white54,
|
||||
unselectedItemColor: text2,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
showUnselectedLabels: true,
|
||||
selectedLabelStyle: TextStyle(fontWeight: FontWeight.w700),
|
||||
unselectedLabelStyle: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -10,6 +10,9 @@ 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/gast_zugang.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 +28,11 @@ class _MemorySpeicher implements TokenSpeicher {
|
||||
}
|
||||
|
||||
Widget _wrap(BakaAuth auth,
|
||||
{required MeloDb db, required CategoryService categories}) {
|
||||
{required MeloDb db,
|
||||
required CategoryService categories,
|
||||
NavidromeService? navidrome,
|
||||
GastZugang? gast}) {
|
||||
final gastZugang = gast ?? GastZugang();
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider<MeloDb>.value(value: db),
|
||||
@@ -34,12 +41,20 @@ Widget _wrap(BakaAuth auth,
|
||||
// Aufräum-Timer von drift im Test-Rahmen hängen.
|
||||
ChangeNotifierProvider<CategoryService>.value(value: categories),
|
||||
ChangeNotifierProvider<BakaAuth>.value(value: auth),
|
||||
ChangeNotifierProvider<GastZugang>.value(value: gastZugang),
|
||||
ChangeNotifierProvider<YtDownloadService>(
|
||||
create: (_) => YtDownloadService(
|
||||
auth: auth,
|
||||
gast: gastZugang,
|
||||
client: MockClient((_) async => http.Response('', 500)),
|
||||
),
|
||||
),
|
||||
// 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 +87,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 +110,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 +212,222 @@ 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(
|
||||
'Der Anmelde-Dialog schlägt den bekannten Navidrome-Benutzer vor, '
|
||||
'nicht immer "Baka"', (tester) async {
|
||||
final auth = BakaAuth(
|
||||
client: MockClient((_) async => http.Response('', 500)),
|
||||
speicher: _MemorySpeicher());
|
||||
final nav = NavidromeService()
|
||||
..setCredentials('https://musik.baka-net.de', 'Tinker', 'falsch');
|
||||
|
||||
await tester.pumpWidget(_wrap(auth,
|
||||
db: db, categories: categories, navidrome: nav));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Beim Baka-Konto anmelden'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final feld = tester.widget<TextField>(find.byType(TextField).first);
|
||||
expect(feld.controller!.text, 'Tinker');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Erfolgreiches manuelles Anmelden übernimmt das neue Passwort auch '
|
||||
'für Navidrome — sonst kommt der Auto-Login-Fehler nach Ablauf des '
|
||||
'Baka-Tokens garantiert wieder', (tester) async {
|
||||
final auth = BakaAuth(
|
||||
client: MockClient((request) async {
|
||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
if (body['password'] == 'neuesPasswort') {
|
||||
return http.Response(jsonEncode({'token': 'jwt-neu'}), 200);
|
||||
}
|
||||
return http.Response('', 500);
|
||||
}),
|
||||
speicher: _MemorySpeicher(),
|
||||
);
|
||||
final nav = NavidromeService()
|
||||
..setCredentials('https://musik.baka-net.de', 'Tinker', 'altesPasswort');
|
||||
|
||||
await tester.pumpWidget(_wrap(auth,
|
||||
db: db, categories: categories, navidrome: nav));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Beim Baka-Konto anmelden'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField).last, 'neuesPasswort');
|
||||
await tester.tap(find.text('Anmelden'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(nav.passwort, 'neuesPasswort');
|
||||
expect(nav.benutzer, 'Tinker');
|
||||
expect(auth.istAngemeldet, isTrue);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Angemeldeter Cloud-Account sieht das Kontingent nach einem Download',
|
||||
(tester) async {
|
||||
final auth = await _angemeldet();
|
||||
|
||||
await tester.pumpWidget(_wrap(auth, db: db, categories: categories));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('von 100 heute'), findsNothing);
|
||||
|
||||
auth.merkeVerbleibend(63);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Noch 63 von 100 heute'), 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);
|
||||
});
|
||||
|
||||
testWidgets('Gast sieht zwei Buttons: Anmelden und Als Gast fortfahren',
|
||||
(tester) async {
|
||||
final auth = BakaAuth(
|
||||
client: MockClient((_) async => http.Response('', 500)),
|
||||
speicher: _MemorySpeicher());
|
||||
|
||||
await tester.pumpWidget(_wrap(auth, db: db, categories: categories));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Beim Baka-Konto anmelden'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Als Gast fortfahren (5 Downloads/Tag)'), findsOneWidget);
|
||||
expect(find.text('YouTube-Adresse'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Als Gast fortfahren holt einen Token und schaltet das Adressfeld frei',
|
||||
(tester) async {
|
||||
final auth = BakaAuth(
|
||||
client: MockClient((_) async => http.Response('', 500)),
|
||||
speicher: _MemorySpeicher());
|
||||
final gast = GastZugang(
|
||||
client: MockClient((_) async =>
|
||||
http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
|
||||
speicher: _MemorySpeicher(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_wrap(auth, db: db, categories: categories, gast: gast));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('YouTube-Adresse'), findsOneWidget);
|
||||
expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget);
|
||||
expect(find.text('YouTube-Cookies des Servers verwenden'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Fehler beim Gast-Token wird angezeigt', (tester) async {
|
||||
final auth = BakaAuth(
|
||||
client: MockClient((_) async => http.Response('', 500)),
|
||||
speicher: _MemorySpeicher());
|
||||
final gast = GastZugang(
|
||||
client: MockClient((_) async => http.Response('', 500)),
|
||||
speicher: _MemorySpeicher(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_wrap(auth, db: db, categories: categories, gast: gast));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Gast-Zugang fehlgeschlagen (500)'), findsOneWidget);
|
||||
expect(find.text('YouTube-Adresse'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Anmelden im Gast-Modus verwirft den Gast-Token und zeigt wieder die '
|
||||
'Wahl', (tester) async {
|
||||
final auth = BakaAuth(
|
||||
client: MockClient((_) async => http.Response('', 500)),
|
||||
speicher: _MemorySpeicher());
|
||||
final gast = GastZugang(
|
||||
client: MockClient((_) async =>
|
||||
http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
|
||||
speicher: _MemorySpeicher(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_wrap(auth, db: db, categories: categories, gast: gast));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('YouTube-Adresse'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Anmelden'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Beim Baka-Konto anmelden'), findsOneWidget);
|
||||
expect(find.text('YouTube-Adresse'), findsNothing);
|
||||
expect(gast.hatToken, isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:melo/downloads/server_titel_screen.dart';
|
||||
import 'package:melo/library/database.dart';
|
||||
import 'package:melo/player/audio_handler.dart';
|
||||
import 'package:melo/player/player_expansion_controller.dart';
|
||||
import 'package:melo/services/navidrome_service.dart';
|
||||
|
||||
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff:
|
||||
/// der spricht über Plattform-Kanäle, die im Widget-Test nicht existieren
|
||||
/// und mit `MissingPluginException` scheitern würden. Alles andere
|
||||
/// (Warteschlange, Mediendaten) bleibt echtes Handler-Verhalten.
|
||||
class _FakeAudioHandler extends MeloAudioHandler {
|
||||
_FakeAudioHandler(MeloDb db) : super(db: db);
|
||||
|
||||
@override
|
||||
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
|
||||
queue.add(items);
|
||||
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late MeloDb db;
|
||||
late _FakeAudioHandler handler;
|
||||
|
||||
void stelleKanaeleRuhig() {
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(call) async => '/tmp/melo-test',
|
||||
);
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
|
||||
(call) async => null,
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
stelleKanaeleRuhig();
|
||||
db = MeloDb(NativeDatabase.memory());
|
||||
handler = _FakeAudioHandler(db);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
handler.dispose();
|
||||
});
|
||||
|
||||
Widget wrap(Widget child, PlayerExpansionController expansion) =>
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider<MeloDb>.value(value: db),
|
||||
Provider<MeloAudioHandler>.value(value: handler),
|
||||
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||
value: expansion),
|
||||
],
|
||||
child: MaterialApp(home: child),
|
||||
);
|
||||
|
||||
testWidgets('Titel antippen öffnet den Vollbild-Player', (tester) async {
|
||||
final navidrome = NavidromeService()
|
||||
..setCredentials('https://musik.baka-net.de', 'Baka', 'geheim');
|
||||
final expansion = PlayerExpansionController(vsync: tester);
|
||||
addTearDown(expansion.dispose);
|
||||
await tester.pumpWidget(wrap(
|
||||
ServerTitelScreen(
|
||||
titel: 'Ablaze',
|
||||
navidrome: navidrome,
|
||||
holeTitel: () async => const [
|
||||
SubsonicSong(id: 's1', titel: 'Feuer', kuenstler: 'X', dauerSekunden: 120),
|
||||
],
|
||||
),
|
||||
expansion,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(expansion.progress, 0.0);
|
||||
|
||||
await tester.tap(find.text('Feuer'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(expansion.progress, 1.0);
|
||||
|
||||
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo beim Abbau des
|
||||
// Baums als offener Fake-Timer stehen.
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
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/gast_zugang.dart';
|
||||
import 'package:melo/services/navidrome_service.dart';
|
||||
import 'package:melo/services/yt_download_service.dart';
|
||||
import 'package:melo/services/yt_search_service.dart';
|
||||
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,
|
||||
GastZugang? gast,
|
||||
}) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider<MeloDb>.value(value: db),
|
||||
ChangeNotifierProvider<LibraryService>.value(value: LibraryService(db)),
|
||||
ChangeNotifierProvider<BakaAuth>.value(value: auth),
|
||||
ChangeNotifierProvider<GastZugang>.value(value: gast ?? GastZugang()),
|
||||
ChangeNotifierProvider<YtSearchService>.value(value: search),
|
||||
ChangeNotifierProvider<YtDownloadService>.value(value: download),
|
||||
// Nur für die Server-User-Prüfung gebraucht (siehe
|
||||
// _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-Server'), 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();
|
||||
});
|
||||
|
||||
testWidgets('Angemeldeter Cloud-Account sieht das Kontingent nach Download',
|
||||
(tester) async {
|
||||
final db = MeloDb(NativeDatabase.memory());
|
||||
final auth = await _angemeldeteAuth();
|
||||
final search = YtSearchService(auth: auth);
|
||||
final download = YtDownloadService(auth: auth);
|
||||
|
||||
await tester.pumpWidget(_wrap(
|
||||
db: db, auth: auth, search: search, download: download));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('von 100 heute'), findsNothing);
|
||||
|
||||
auth.merkeVerbleibend(98);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Noch 98 von 100 heute'), findsOneWidget);
|
||||
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// Kein Widget-Test für den Download-Knopf selbst: YtDownloadService.
|
||||
// herunterladen() kombiniert Future.any mit .timeout() — dessen Timer
|
||||
// bleibt unter Flutters fake_async-Testbindung immer als "pending" stehen
|
||||
// (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.
|
||||
|
||||
testWidgets('Gast sieht den Button "Als Gast fortfahren"', (tester) async {
|
||||
final db = MeloDb(NativeDatabase.memory());
|
||||
final auth = BakaAuth(
|
||||
client: MockClient((_) async => http.Response('', 500)),
|
||||
speicher: _MemorySpeicher());
|
||||
final gast = GastZugang(
|
||||
client: MockClient((_) async => http.Response('', 500)),
|
||||
speicher: _MemorySpeicher(),
|
||||
);
|
||||
final search = YtSearchService(auth: auth, gast: gast);
|
||||
final download = YtDownloadService(auth: auth, gast: gast);
|
||||
|
||||
await tester.pumpWidget(_wrap(
|
||||
db: db, auth: auth, search: search, download: download, gast: gast));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Als Gast fortfahren (5 Downloads/Tag)'), findsOneWidget);
|
||||
|
||||
await db.close();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Als Gast fortfahren blendet den Baka-Hinweis aus und zeigt den Zähler',
|
||||
(tester) async {
|
||||
final db = MeloDb(NativeDatabase.memory());
|
||||
final auth = BakaAuth(
|
||||
client: MockClient((_) async => http.Response('', 500)),
|
||||
speicher: _MemorySpeicher());
|
||||
final gast = GastZugang(
|
||||
client: MockClient((_) async =>
|
||||
http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
|
||||
speicher: _MemorySpeicher(),
|
||||
);
|
||||
final search = YtSearchService(auth: auth, gast: gast);
|
||||
final download = YtDownloadService(auth: auth, gast: gast);
|
||||
|
||||
await tester.pumpWidget(_wrap(
|
||||
db: db, auth: auth, search: search, download: download, gast: gast));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Baka-Anmeldung'), findsNothing);
|
||||
expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget);
|
||||
|
||||
await db.close();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Anmelden im Gast-Modus verwirft den Gast-Token und zeigt wieder den '
|
||||
'Hinweis', (tester) async {
|
||||
final db = MeloDb(NativeDatabase.memory());
|
||||
final auth = BakaAuth(
|
||||
client: MockClient((_) async => http.Response('', 500)),
|
||||
speicher: _MemorySpeicher());
|
||||
final search = YtSearchService(auth: auth);
|
||||
final download = YtDownloadService(auth: auth);
|
||||
final gast = GastZugang(
|
||||
client: MockClient((_) async =>
|
||||
http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
|
||||
speicher: _MemorySpeicher(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_wrap(
|
||||
db: db, auth: auth, search: search, download: download, gast: gast));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Anmelden'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Baka-Server'), findsOneWidget);
|
||||
expect(gast.hatToken, isFalse);
|
||||
|
||||
await db.close();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/library/category_service.dart';
|
||||
import 'package:melo/library/database.dart';
|
||||
import 'package:melo/library/library_service.dart';
|
||||
import 'package:melo/library/playlist_service.dart';
|
||||
import 'package:melo/main.dart';
|
||||
import 'package:melo/player/audio_handler.dart';
|
||||
import 'package:melo/player/player_expansion_controller.dart';
|
||||
import 'package:melo/services/baka_auth.dart';
|
||||
import 'package:melo/services/download_service.dart';
|
||||
import 'package:melo/services/gast_zugang.dart';
|
||||
import 'package:melo/services/melo_cloud_service.dart';
|
||||
import 'package:melo/services/navidrome_service.dart';
|
||||
import 'package:melo/services/offline_mode.dart';
|
||||
import 'package:melo/services/sync_service.dart';
|
||||
import 'package:melo/services/yt_download_service.dart';
|
||||
import 'package:melo/services/yt_search_service.dart';
|
||||
import 'package:melo/settings/app_settings.dart';
|
||||
import 'package: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;
|
||||
late GastZugang gast;
|
||||
MeloAudioHandler? handler;
|
||||
PlayerExpansionController? expansion;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
db = MeloDb(NativeDatabase.memory());
|
||||
kategorien = CategoryService(db);
|
||||
bibliothek = LibraryService(db);
|
||||
listen = PlaylistService(db);
|
||||
auth = BakaAuth(speicher: _MemorySpeicher());
|
||||
einstellungen = AppSettings();
|
||||
offline = OfflineMode();
|
||||
gast = GastZugang();
|
||||
ytDownload = YtDownloadService(auth: auth, gast: gast);
|
||||
ytSearch = YtSearchService(auth: auth, gast: gast);
|
||||
downloads = DownloadService(db: db, navidrome: NavidromeService());
|
||||
sync = SyncService(db: db, cloud: MeloCloudService(auth: auth));
|
||||
});
|
||||
|
||||
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(WidgetTester tester) {
|
||||
// Der Audio-Handler erst hier: sein Zwischenspeicher greift auf einen
|
||||
// Plattform-Kanal zu, den es im setUp noch nicht gibt.
|
||||
handler ??= MeloAudioHandler(db: db);
|
||||
// PlayerExpansionController sitzt seit dem Provider-Scope-Fix (main.dart)
|
||||
// oberhalb von MaterialApp/HomeShell, nicht mehr innerhalb von
|
||||
// HomeShell.build() — hier deshalb dieselbe Verdrahtung nachgebaut.
|
||||
expansion ??= PlayerExpansionController(vsync: tester);
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider<MeloDb>.value(value: db),
|
||||
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<GastZugang>.value(value: gast),
|
||||
ChangeNotifierProvider<YtDownloadService>.value(value: ytDownload),
|
||||
ChangeNotifierProvider<YtSearchService>.value(value: ytSearch),
|
||||
ChangeNotifierProvider<DownloadService>.value(value: downloads),
|
||||
ChangeNotifierProvider<SyncService>.value(value: sync),
|
||||
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||
value: expansion!),
|
||||
],
|
||||
child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
expansion?.dispose();
|
||||
expansion = null;
|
||||
// Die Zeit ein Stück vorstellen: beim Abbauen melden sich die
|
||||
// Datenbank-Ströme ab und planen dafür eine Aufräum-Aufgabe ein. Ein
|
||||
// pump() ohne Dauer lässt sie liegen, und der Test-Rahmen meldet dann
|
||||
// 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(tester));
|
||||
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(tester));
|
||||
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(tester));
|
||||
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(tester));
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7,11 +7,16 @@ import 'package:melo/library/library_service.dart';
|
||||
import 'package:melo/library/playlist_service.dart';
|
||||
import 'package:melo/main.dart';
|
||||
import 'package:melo/player/audio_handler.dart';
|
||||
import 'package:melo/player/player_expansion_controller.dart';
|
||||
import 'package:melo/services/baka_auth.dart';
|
||||
import 'package:melo/services/download_service.dart';
|
||||
import 'package:melo/services/gast_zugang.dart';
|
||||
import 'package:melo/services/melo_cloud_service.dart';
|
||||
import 'package:melo/services/navidrome_service.dart';
|
||||
import 'package:melo/services/offline_mode.dart';
|
||||
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,12 +35,13 @@ 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);
|
||||
final handler = MeloAudioHandler(db: db);
|
||||
final auth = BakaAuth(speicher: _MemorySpeicher());
|
||||
final expansion = PlayerExpansionController(vsync: tester);
|
||||
|
||||
await tester.pumpWidget(MultiProvider(
|
||||
providers: [
|
||||
@@ -47,11 +53,18 @@ void main() {
|
||||
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
|
||||
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
|
||||
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
|
||||
ChangeNotifierProvider<GastZugang>(create: (_) => GastZugang()),
|
||||
ChangeNotifierProvider<YtDownloadService>(
|
||||
create: (_) => YtDownloadService(auth: auth)),
|
||||
ChangeNotifierProvider<YtSearchService>(
|
||||
create: (_) => YtSearchService(auth: auth)),
|
||||
ChangeNotifierProvider<DownloadService>(
|
||||
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
|
||||
ChangeNotifierProvider<SyncService>(
|
||||
create: (_) => SyncService(
|
||||
db: db, cloud: MeloCloudService(auth: auth))),
|
||||
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||
value: expansion),
|
||||
],
|
||||
child: const MaterialApp(home: HomeShell()),
|
||||
));
|
||||
@@ -60,9 +73,82 @@ 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();
|
||||
expansion.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
testWidgets('Zurück-Geste auf einem Nicht-Start-Tab wechselt zu '
|
||||
'"Meine Musik" statt die App zu minimieren', (tester) async {
|
||||
final db = MeloDb(NativeDatabase.memory());
|
||||
final lib = LibraryService(db);
|
||||
final playlists = PlaylistService(db);
|
||||
final handler = MeloAudioHandler(db: db);
|
||||
final auth = BakaAuth(speicher: _MemorySpeicher());
|
||||
final expansion = PlayerExpansionController(vsync: tester);
|
||||
|
||||
await tester.pumpWidget(MultiProvider(
|
||||
providers: [
|
||||
Provider<MeloDb>.value(value: db),
|
||||
ChangeNotifierProvider<LibraryService>.value(value: lib),
|
||||
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
|
||||
Provider<MeloAudioHandler>.value(value: handler),
|
||||
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
|
||||
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
|
||||
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
|
||||
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
|
||||
ChangeNotifierProvider<GastZugang>(create: (_) => GastZugang()),
|
||||
ChangeNotifierProvider<YtDownloadService>(
|
||||
create: (_) => YtDownloadService(auth: auth)),
|
||||
ChangeNotifierProvider<YtSearchService>(
|
||||
create: (_) => YtSearchService(auth: auth)),
|
||||
ChangeNotifierProvider<DownloadService>(
|
||||
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
|
||||
ChangeNotifierProvider<SyncService>(
|
||||
create: (_) => SyncService(
|
||||
db: db, cloud: MeloCloudService(auth: auth))),
|
||||
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||
value: expansion),
|
||||
],
|
||||
child: const MaterialApp(home: HomeShell()),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
// Auf "Favoriten" wechseln (Index 3) — kein Navigator.push, nur der Tab.
|
||||
await tester.tap(find.descendant(
|
||||
of: find.byType(BottomNavigationBar), matching: find.text('Favoriten')));
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester
|
||||
.widget<BottomNavigationBar>(find.byType(BottomNavigationBar))
|
||||
.currentIndex,
|
||||
3);
|
||||
|
||||
// System-Zurück-Geste simulieren — derselbe Weg, über den auch das
|
||||
// echte Android-Zurück-Gesten-Signal bei WidgetsApp ankommt.
|
||||
final widgetsAppState = tester.state(find.byType(WidgetsApp));
|
||||
// ignore: avoid_dynamic_calls
|
||||
await (widgetsAppState as dynamic).didPopRoute();
|
||||
await tester.pump();
|
||||
|
||||
// Ohne Abfangen bliebe der Tab unverändert (Flutter minimiert dann die
|
||||
// App statt zu navigieren) — die Geste muss stattdessen zu Tab 0 führen.
|
||||
expect(
|
||||
tester
|
||||
.widget<BottomNavigationBar>(find.byType(BottomNavigationBar))
|
||||
.currentIndex,
|
||||
0);
|
||||
|
||||
// Baum abbauen, bevor der Test endet: der Positions-Timer des Audio-
|
||||
// Handlers läuft sonst über das Testende hinaus weiter, und der
|
||||
// Test-Rahmen meldet einen offenen Timer (wie in hauptmenue_test.dart
|
||||
// dokumentiert). Aus demselben Grund bewusst kein db.close() nach
|
||||
// einem tester.tap.
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
handler.dispose();
|
||||
expansion.dispose();
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/library/library_service.dart';
|
||||
|
||||
// Bug (Tinker): Auf Android zeigten die Einstellungen zwei Buttons mit
|
||||
// identischer Aufgabe ("Musik scannen" und "Erneut scannen") — beide riefen
|
||||
// auf Android denselben Geräte-weiten MediaStore-Scan auf. Die Sichtbarkeit
|
||||
// des zweiten Buttons hängt eigentlich von Platform.isAndroid ab, das sich
|
||||
// in Tests nicht sauber auf "Android" umschalten lässt — deshalb ist die
|
||||
// Entscheidung hier als pure, plattformunabhängig testbare Funktion
|
||||
// ausgelagert.
|
||||
void main() {
|
||||
group('zeigeErneutScannenButton', () {
|
||||
test('auf Android ausgeblendet (identisch zu "Musik scannen")', () {
|
||||
expect(zeigeErneutScannenButton(true), isFalse);
|
||||
});
|
||||
|
||||
test('auf Desktop angezeigt (echter Unterschied: bekannte Ordner erneut scannen)', () {
|
||||
expect(zeigeErneutScannenButton(false), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/library/database.dart';
|
||||
|
||||
Future<void> _insertSong(MeloDb db, String id, {bool deleted = false}) {
|
||||
return db.into(db.songs).insert(SongsCompanion.insert(
|
||||
id: id,
|
||||
path: '/$id.mp3',
|
||||
title: id,
|
||||
dateAddedMs: 0,
|
||||
updatedAtMs: 0,
|
||||
deleted: Value(deleted),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _play(MeloDb db, String songId, int playedAtMs) {
|
||||
return db.into(db.playbackHistory).insert(PlaybackHistoryCompanion.insert(
|
||||
songId: songId,
|
||||
positionMs: 0,
|
||||
playedAtMs: playedAtMs,
|
||||
));
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('ein mehrfach gespielter Song erscheint nur einmal, an der Position '
|
||||
'seines jüngsten Abspielens', () async {
|
||||
final db = MeloDb(NativeDatabase.memory());
|
||||
await _insertSong(db, 'a');
|
||||
await _insertSong(db, 'b');
|
||||
await _play(db, 'a', 1000);
|
||||
await _play(db, 'b', 2000);
|
||||
await _play(db, 'a', 3000); // a erneut gespielt, jetzt jüngster Play
|
||||
|
||||
final result = await db.watchRecentlyPlayed().first;
|
||||
|
||||
expect(result.map((s) => s.id).toList(), ['a', 'b']);
|
||||
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('mehrere Songs erscheinen absteigend nach ihrem letzten Abspielen',
|
||||
() async {
|
||||
final db = MeloDb(NativeDatabase.memory());
|
||||
await _insertSong(db, 'a');
|
||||
await _insertSong(db, 'b');
|
||||
await _insertSong(db, 'c');
|
||||
await _play(db, 'a', 1000);
|
||||
await _play(db, 'b', 3000);
|
||||
await _play(db, 'c', 2000);
|
||||
|
||||
final result = await db.watchRecentlyPlayed().first;
|
||||
|
||||
expect(result.map((s) => s.id).toList(), ['b', 'c', 'a']);
|
||||
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('ein gelöschter Song mit Wiedergabe-Historie erscheint nicht',
|
||||
() async {
|
||||
final db = MeloDb(NativeDatabase.memory());
|
||||
await _insertSong(db, 'a', deleted: true);
|
||||
await _insertSong(db, 'b');
|
||||
await _play(db, 'a', 2000);
|
||||
await _play(db, 'b', 1000);
|
||||
|
||||
final result = await db.watchRecentlyPlayed().first;
|
||||
|
||||
expect(result.map((s) => s.id).toList(), ['b']);
|
||||
|
||||
await db.close();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:melo/library/category_service.dart';
|
||||
import 'package:melo/library/database.dart';
|
||||
import 'package:melo/library/playlist_service.dart';
|
||||
import 'package:melo/library/song_list.dart';
|
||||
import 'package:melo/player/audio_handler.dart';
|
||||
import 'package:melo/player/player_expansion_controller.dart';
|
||||
import 'package:melo/settings/app_settings.dart';
|
||||
|
||||
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff:
|
||||
/// der spricht über Plattform-Kanäle, die im Widget-Test nicht existieren
|
||||
/// und mit `MissingPluginException` scheitern würden. Alles andere
|
||||
/// (Warteschlange, Mediendaten) bleibt echtes Handler-Verhalten.
|
||||
class _FakeAudioHandler extends MeloAudioHandler {
|
||||
_FakeAudioHandler(MeloDb db) : super(db: db);
|
||||
|
||||
@override
|
||||
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
|
||||
queue.add(items);
|
||||
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late MeloDb db;
|
||||
late CategoryService kategorien;
|
||||
late PlaylistService playlists;
|
||||
late AppSettings einstellungen;
|
||||
late _FakeAudioHandler handler;
|
||||
|
||||
void stelleKanaeleRuhig() {
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(call) async => '/tmp/melo-test',
|
||||
);
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
|
||||
(call) async => null,
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
stelleKanaeleRuhig();
|
||||
db = MeloDb(NativeDatabase.memory());
|
||||
kategorien = CategoryService(db);
|
||||
playlists = PlaylistService(db);
|
||||
einstellungen = AppSettings();
|
||||
await einstellungen.init();
|
||||
handler = _FakeAudioHandler(db);
|
||||
await db.into(db.songs).insert(SongsCompanion.insert(
|
||||
id: 'song-1',
|
||||
path: '/music/roses.mp3',
|
||||
title: 'Roses',
|
||||
artist: const Value('Neoni'),
|
||||
dateAddedMs: 0,
|
||||
updatedAtMs: 0,
|
||||
));
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
handler.dispose();
|
||||
kategorien.dispose();
|
||||
playlists.dispose();
|
||||
});
|
||||
|
||||
Song derSong() => Song(
|
||||
id: 'song-1',
|
||||
path: '/music/roses.mp3',
|
||||
title: 'Roses',
|
||||
artist: 'Neoni',
|
||||
dateAddedMs: 0,
|
||||
updatedAtMs: 0,
|
||||
deleted: false,
|
||||
playCount: 0,
|
||||
categoriesEdited: false,
|
||||
metadataEdited: false,
|
||||
);
|
||||
|
||||
testWidgets('Song antippen öffnet den Vollbild-Player mit großem Cover',
|
||||
(tester) async {
|
||||
final expansion = PlayerExpansionController(vsync: tester);
|
||||
addTearDown(expansion.dispose);
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider<MeloDb>.value(value: db),
|
||||
Provider<MeloAudioHandler>.value(value: handler),
|
||||
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
|
||||
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
|
||||
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
|
||||
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||
value: expansion),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SongZeile(
|
||||
song: derSong(),
|
||||
warteschlange: [derSong()],
|
||||
index: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(expansion.progress, 0.0);
|
||||
|
||||
await tester.tap(find.text('Roses'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(expansion.progress, 1.0);
|
||||
|
||||
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo (Favoriten-Herz)
|
||||
// beim Abbau des Baums als offener Fake-Timer stehen — dieselbe
|
||||
// Aufräum-Zeile wie in song_zeile_lauf_test.dart.
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
});
|
||||
}
|
||||
@@ -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 Z–A', () {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user