Fuenf P0-Fehler behoben: Benachrichtigung, Offline, Cover, Warteschlange
Ergebnis einer Durchsicht der App gegen den Massstab "Apple Music". Alle fuenf Punkte waren keine fehlenden Funktionen, sondern Versprechen, die die Oberflaeche gab und der Code nicht hielt. - POST_NOTIFICATIONS fehlte im Manifest. Auf Android 13+ blieb die Wiedergabe-Benachrichtigung dadurch unsichtbar. Das Recht wird erst erfragt, wenn wirklich ein Titel laeuft. - Der Offline-Schalter wurde nirgends gelesen. Er greift jetzt an allen drei Wegen in die Warteschlange; uebersprungene Titel werden benannt. - CoverImage kannte nur file://, und der Server-Browser gab die Cover-Adresse gar nicht erst mit. Server-Titel waren deshalb immer ein graues Notensymbol. - Warteschlange entfernen/umsortieren baute die ganze Queue neu auf: hoerbarer Aussetzer und doppelte Wiedergabe-Zaehlung. Jetzt mutiert nur der betroffene Eintrag, die Buchfuehrung steht vor dem Player. - Das Herz bei Server-Titeln legte einen Favoriten mit leerer Kennung an, der nie in der Liste auftauchte. Es ist jetzt ausgegraut. Nachgebessert aus dem Code-Review: Offline-Meldung an die Stelle geholt, wo sie greifen kann; Queue-Buchfuehrung vor den Player gezogen; Fehlerpfad fuer die Berechtigungs-Abfrage ergaenzt. 360 Tests gruen (vorher 328), flutter analyze ohne Befund, Release-APK gebaut und POST_NOTIFICATIONS darin nachgeprueft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
This commit is contained in:
co-authored by
Claude Opus 5
parent
34d6d8e52a
commit
e6d5f1c9ee
@@ -0,0 +1,361 @@
|
||||
# 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: **Alben als Browse-Achse**, **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:** Die fünf P0-Punkte (K1–K5) sind
|
||||
> behoben, siehe CHANGELOG. G9 und G10 kamen bei der Umsetzung dazu und
|
||||
> sind 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
|
||||
**Ort:** `lib/library/song_grouping.dart` · `lib/library/my_music_screen.dart:88`
|
||||
**Kategorie:** Funktionsumfang
|
||||
|
||||
`Songs.album` ist eine Textspalte, aber es gibt weder eine Alben-Liste noch einen
|
||||
Album-Detail-Screen. `song_grouping.dart` kann nur `groupByArtist`. Die Unterreiter
|
||||
heißen **Lieder / Kategorie / Künstler** — im Referenz-Screenshot
|
||||
(`ui-referenz/bugs/img_2d5761551808.jpg`, das ist Melo selbst) stand dort noch
|
||||
**Songs / Künstler / Alben**. Die Achse ist also verlorengegangen.
|
||||
|
||||
**Wirkung:** Album ist neben Playlist die wichtigste Einheit, in der Menschen Musik
|
||||
denken. Ohne sie ist "Bibliothek" eine flache Titelliste — brauchbar bei 200 Titeln,
|
||||
unbrauchbar bei 5.000.
|
||||
|
||||
**Empfehlung:** `groupByAlbum` (analog zu `groupByArtist`, existierende `_groupBy`-
|
||||
Hilfsfunktion nutzt man mit), vierter Unterreiter, Album-Screen mit großem Cover,
|
||||
Künstler, Jahr, Titelzahl, Gesamtdauer und "Abspielen / Zufällig".
|
||||
|
||||
---
|
||||
|
||||
### 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._
|
||||
@@ -5,6 +5,93 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 🚑 Fünf P0-Fehler behoben: Benachrichtigung, Offline, Cover, Warteschlange (2026-08-23)
|
||||
|
||||
Ergebnis einer vollständigen Durchsicht der App gegen den Maßstab „Apple
|
||||
Music". Fünf Punkte waren keine fehlenden Funktionen, sondern **Versprechen,
|
||||
die die App gab und der Code nicht hielt**.
|
||||
|
||||
- 🔔 **Die Wiedergabe-Benachrichtigung erschien auf Android 13+ gar nicht.**
|
||||
Dafür braucht das System die Berechtigung `POST_NOTIFICATIONS` — sie fehlte
|
||||
im Manifest, auch im fertig gebauten APK. Die Musik lief, aber Notification
|
||||
und die Bedienknöpfe darin blieben unsichtbar; steuern ließ sich nur in der
|
||||
geöffneten App. Die Berechtigung ist jetzt drin und wird **erst dann**
|
||||
erfragt, wenn wirklich ein Titel läuft — beim kalten Start käme die Frage
|
||||
ohne Zusammenhang, und zweimal weggetippt sperrt Android sie dauerhaft.
|
||||
|
||||
- 📴 **Der Offline-Schalter tat nichts.** Er speicherte brav ein Ja/Nein, aber
|
||||
**niemand las es**: die App streamte weiter und verbrauchte Mobilfunkdaten,
|
||||
obwohl „Offline-Modus" anstand. Jetzt greift er an allen drei Wegen in die
|
||||
Warteschlange — Abspielen, „Als Nächstes", „Zur Warteschlange". Titel vom
|
||||
Gerät laufen immer, Titel vom Server nur, wenn sie schon hier liegen. Was
|
||||
übersprungen wird, sagt die App: *„Offline-Modus: 3 Titel übersprungen —
|
||||
nicht heruntergeladen"*, statt still eine kürzere Liste zu spielen.
|
||||
|
||||
- 🖼️ **Titel vom Server hatten nie ein Coverbild.** Die App konnte Cover nur
|
||||
aus Dateien laden; das Bild vom Navidrome-Server liegt aber hinter einer
|
||||
Web-Adresse. Jeder Server-Titel war deshalb ein graues Notensymbol — in der
|
||||
Liste, im Player, auf dem Sperrbildschirm. Jetzt lädt die App auch
|
||||
Web-Cover, und der Server-Browser gibt die Adresse überhaupt erst mit
|
||||
(vorher wurde sie an beiden Stellen schlicht vergessen).
|
||||
|
||||
- ⏭️ **Warteschlange umsortieren riss die Wiedergabe auf.** Jedes Entfernen
|
||||
und jedes Verschieben baute die *ganze* Warteschlange neu auf: hörbarer
|
||||
Aussetzer, Sprung an den Titelanfang — und der laufende Titel wurde dabei
|
||||
**jedes Mal erneut als Wiedergabe gezählt**. Die Sortierung „Wie oft
|
||||
abgespielt" zählte damit Queue-Bedienung statt Hören. Jetzt wird nur der
|
||||
eine betroffene Eintrag bewegt; die Wiedergabe merkt davon nichts.
|
||||
|
||||
- 🤍 **Das Herz bei Server-Titeln log.** Ein Titel, der nur am Server liegt,
|
||||
hat keinen Eintrag in der Bibliothek — das Herz bekam deshalb eine leere
|
||||
Kennung, füllte sich beim Antippen und legte einen Favoriten an, der in der
|
||||
Favoritenliste nie auftauchte. Es ist jetzt sichtbar ausgegraut, mit dem
|
||||
Hinweis „Nur für Titel in deiner Bibliothek".
|
||||
|
||||
- ⚡ **Nebenwirkung der Offline-Umstellung, aber eine gute:** „Als Nächstes"
|
||||
und „Zur Warteschlange" sahen bisher gar nicht im Zwischenspeicher nach und
|
||||
streamten einen Server-Titel auch dann, wenn er längst auf dem Gerät lag.
|
||||
Alle drei Wege in die Warteschlange bauen die Abspielquelle jetzt an
|
||||
derselben Stelle — ein bereits geladener Titel läuft immer von der Platte.
|
||||
|
||||
- 🧹 **Nebenbei:** „📴 Offline-Modus" heißt jetzt ohne Emoji „Offline-Modus"
|
||||
(Emoji sehen auf jedem Hersteller-Handy anders aus und lassen sich nicht
|
||||
einfärben), und der Untertitel sagt, was der Schalter wirklich tut.
|
||||
|
||||
- 🔍 **Aus dem Code-Review nachgebessert (drei Punkte):**
|
||||
**(1)** Die Offline-Meldung hing an der falschen Stelle. Sie stand in der
|
||||
Bibliotheksliste — die zeigt aber nur Titel vom Gerät, dort wird nie etwas
|
||||
übersprungen. Beim Server-Album, wo es wirklich passiert, wurde sie
|
||||
verworfen. Jetzt sitzt sie dort, wo sie greifen kann; und „nichts davon ist
|
||||
heruntergeladen" erscheint nicht mehr als *„Fehler: …"* — das ist kein
|
||||
Fehler, sondern die eingestellte Wahl.
|
||||
**(2)** Beim Umsortieren wurde die Warteschlange erst *nach* dem Player
|
||||
nachgeführt. In der Lücke dazwischen konnte der Player schon einen neuen
|
||||
Titelindex melden, während die App noch die alte Liste kannte — falscher
|
||||
Titel im Player, Wiedergabe für das falsche Lied gezählt. Buchführung steht
|
||||
jetzt vor dem Player, genau wie beim normalen Abspielen.
|
||||
**(3)** Die Abfrage der Benachrichtigungs-Berechtigung hatte keinen
|
||||
Fehlerpfad. Läuft beim Start noch die Abfrage der Musik-Berechtigung, wirft
|
||||
Android — das wäre als unbehandelter Fehler durchgeschlagen. Jetzt wird es
|
||||
nur protokolliert; die Wiedergabe läuft weiter, nur eben ohne
|
||||
Benachrichtigung.
|
||||
|
||||
- ✅ **Getestet:** 360 Tests grün (vorher 328), `flutter analyze` ohne Befund,
|
||||
Release-APK gebaut. Im fertigen APK ist `POST_NOTIFICATIONS` nachgeprüft
|
||||
enthalten. Neu: 39 Tests für Offline-Filter, Warteschlangen-Mutation,
|
||||
Zähler-Nachführung, Server-Cover und das ausgegraute Herz. Entfallen: 9
|
||||
Tests für `newPlayingIndex` — die Funktion gab es nur, um nach dem
|
||||
Neuaufbau der Warteschlange den laufenden Titel wiederzufinden, und den
|
||||
Neuaufbau gibt es nicht mehr.
|
||||
|
||||
- 📋 **Nicht in dieser Runde:** Alben-Ansicht, Suche über den Server,
|
||||
gezieltes Herunterladen und die Cache-Obergrenze. Die stehen als P1 im
|
||||
Review-Bericht unter `.ui-design/reviews/melo_vollwertigkeit_20260823.md`.
|
||||
Dazu zwei Punkte, die erst bei dieser Arbeit auffielen und dort als **G9**
|
||||
und **G10** stehen: `stop()` bricht den Positions-Timer dauerhaft ab (nach
|
||||
dem Wegwischen der Benachrichtigung merkt sich die App keine Hörposition
|
||||
mehr — vorbestehend), und die Server-Cover haben nur einen Zwischenspeicher
|
||||
im Arbeitsspeicher, keinen auf der Platte.
|
||||
|
||||
### 🏷️ Der zweite Tab heißt wieder „Download" (2026-08-21)
|
||||
|
||||
- 📥 **Aus „Online" wird „Download".** Der Bereich behält seinen Inhalt
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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';
|
||||
@@ -8,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../library/category_service.dart';
|
||||
import '../library/database.dart';
|
||||
import '../library/library_service.dart';
|
||||
import '../library/song_media.dart';
|
||||
import '../player/audio_handler.dart';
|
||||
import '../services/baka_auth.dart';
|
||||
import '../services/media_store.dart';
|
||||
@@ -306,19 +306,20 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
);
|
||||
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);
|
||||
final items = [for (final s in songs) subsonicToMediaItem(s, _nav)];
|
||||
final uebersprungen = await handler.loadPlaylist(items);
|
||||
if (mounted && uebersprungen > 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(offlineHinweis(uebersprungen))),
|
||||
);
|
||||
}
|
||||
} on NichtsOfflineVerfuegbar catch (e) {
|
||||
// Kein Fehler, sondern die gewählte Einstellung — deshalb ohne
|
||||
// "Fehler:" davor, sonst sucht der Nutzer eine Störung.
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$e')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
@@ -341,19 +342,18 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
);
|
||||
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);
|
||||
final items = [for (final s in songs) subsonicToMediaItem(s, _nav)];
|
||||
final uebersprungen = await handler.loadPlaylist(items);
|
||||
if (mounted && uebersprungen > 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(offlineHinweis(uebersprungen))),
|
||||
);
|
||||
}
|
||||
} on NichtsOfflineVerfuegbar catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$e')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+24
-11
@@ -83,6 +83,11 @@ class SongList extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// Menü hinter dem Drei-Punkte-Symbol einer Songzeile.
|
||||
///
|
||||
/// 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, Song song) async {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
final categories = context.read<CategoryService>();
|
||||
@@ -112,9 +117,11 @@ 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 +131,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(
|
||||
@@ -178,20 +187,24 @@ class SongList extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await playSongs(
|
||||
final uebersprungen = 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')),
|
||||
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, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
@@ -12,6 +13,7 @@ import 'library/database.dart';
|
||||
import 'library/favorites_screen.dart';
|
||||
import 'library/library_service.dart';
|
||||
import 'library/my_music_screen.dart';
|
||||
import 'library/permissions.dart';
|
||||
import 'library/playlist_service.dart';
|
||||
import 'library/search_screen.dart';
|
||||
import 'player/audio_effects.dart';
|
||||
@@ -81,6 +83,12 @@ Future<void> main() async {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -140,6 +148,24 @@ 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(_frageBenachrichtigungsRecht(context.read<MeloAudioHandler>()));
|
||||
}
|
||||
|
||||
/// Fragt das Recht auf die Wiedergabe-Benachrichtigung, sobald der erste
|
||||
/// Titel läuft. Beim kalten Start käme die Frage ohne Zusammenhang — und
|
||||
/// zweimal weggetippt sperrt Android sie dauerhaft.
|
||||
///
|
||||
/// Scheitern darf das folgenlos: `permission_handler` wirft, wenn beim
|
||||
/// Start noch die Abfrage der Musik-Berechtigung offen ist. Die Wiedergabe
|
||||
/// läuft dann trotzdem, nur ohne Benachrichtigung — daraus einen
|
||||
/// unbehandelten Fehler zu machen, wäre die schlechtere Antwort.
|
||||
Future<void> _frageBenachrichtigungsRecht(MeloAudioHandler handler) async {
|
||||
try {
|
||||
await handler.mediaItem.firstWhere((item) => item != null);
|
||||
await ensureNotificationPermission();
|
||||
} catch (e) {
|
||||
logger.warning('Benachrichtigungs-Recht nicht erfragt: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+178
-24
@@ -36,6 +36,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.
|
||||
///
|
||||
@@ -60,6 +97,34 @@ bool nutztServerCache(MediaItem item) => navidromeIdOf(item) != null;
|
||||
Uri quelleFuer(MediaItem item, {String? cacheTreffer}) =>
|
||||
cacheTreffer != null ? Uri.file(cacheTreffer) : Uri.parse(item.id);
|
||||
|
||||
/// Ob [item] ohne Netz läuft: Titel vom Gerät immer, Server-Titel nur mit
|
||||
/// vollständigem Cache-Treffer.
|
||||
bool istOfflineVerfuegbar(MediaItem item, {required bool cacheTreffer}) =>
|
||||
navidromeIdOf(item) == null || cacheTreffer;
|
||||
|
||||
/// Der Startindex, nachdem Titel aus der Warteschlange gefiltert wurden.
|
||||
/// [behalten] hat einen Eintrag je ursprünglichem Titel.
|
||||
///
|
||||
/// Fällt der gewählte Titel selbst weg, rückt der nächste behaltene an seine
|
||||
/// Stelle — der Nutzer landet dort, wo er getippt hat, und nicht am Anfang.
|
||||
int startIndexNachFilter(List<bool> behalten, int startIndex) {
|
||||
var davor = 0;
|
||||
for (var i = 0; i < startIndex && i < behalten.length; i++) {
|
||||
if (behalten[i]) davor++;
|
||||
}
|
||||
final gesamt = behalten.where((b) => b).length;
|
||||
if (gesamt == 0) return 0;
|
||||
return davor.clamp(0, gesamt - 1);
|
||||
}
|
||||
|
||||
/// Im Offline-Modus ist keiner der gewählten Titel ohne Netz abspielbar.
|
||||
class NichtsOfflineVerfuegbar implements Exception {
|
||||
const NichtsOfflineVerfuegbar();
|
||||
|
||||
@override
|
||||
String toString() => 'Im Offline-Modus ist keiner dieser Titel verfügbar';
|
||||
}
|
||||
|
||||
/// Kern der Wiedergabe: kapselt just_audio hinter audio_service,
|
||||
/// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren.
|
||||
class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
@@ -79,6 +144,10 @@ 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;
|
||||
|
||||
MeloAudioHandler({required this.db}) {
|
||||
_cache = CacheManager();
|
||||
_cache.init();
|
||||
@@ -159,40 +228,51 @@ 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;
|
||||
|
||||
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();
|
||||
@@ -202,6 +282,28 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
if (navId != null) {
|
||||
unawaited(_nav.ladeInCache(navId, Uri.parse(item.id), _cache));
|
||||
}
|
||||
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);
|
||||
final treffer = navId == null
|
||||
? null
|
||||
: await _cache.getCachedFile(NavidromeService.cacheSchluessel(navId));
|
||||
if (offlineOnly &&
|
||||
!istOfflineVerfuegbar(item, cacheTreffer: treffer != null)) {
|
||||
return null;
|
||||
}
|
||||
return AudioSource.uri(
|
||||
quelleFuer(item, cacheTreffer: treffer?.path),
|
||||
tag: item,
|
||||
);
|
||||
}
|
||||
|
||||
/// Position, an der [item] fortgesetzt wird, oder `null` für den Anfang.
|
||||
@@ -228,21 +330,73 @@ 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);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -9,9 +9,9 @@ import 'audio_handler.dart';
|
||||
/// Warteschlangen-Ansicht: aktuelle Queue anzeigen, zu einem Titel springen,
|
||||
/// Titel entfernen oder per Drag & Drop umsortieren.
|
||||
///
|
||||
/// Der `MeloAudioHandler` bietet keine Live-Queue-Mutation (kein
|
||||
/// `removeQueueItem`/`moveQueueItem`), daher wird die Queue clientseitig neu
|
||||
/// berechnet und per `loadPlaylist` neu geladen.
|
||||
/// Der `MeloAudioHandler` mutiert die Warteschlange direkt
|
||||
/// ([MeloAudioHandler.removeQueueItemAt], [MeloAudioHandler.moveQueueItem]) —
|
||||
/// die laufende Wiedergabe bleibt dabei unangetastet.
|
||||
class QueueScreen extends StatelessWidget {
|
||||
const QueueScreen({super.key});
|
||||
|
||||
@@ -37,7 +37,7 @@ class QueueScreen extends StatelessWidget {
|
||||
return ReorderableListView.builder(
|
||||
itemCount: queue.length,
|
||||
onReorderItem: (oldIndex, newIndex) =>
|
||||
_reorder(handler, oldIndex, newIndex),
|
||||
handler.moveQueueItem(oldIndex, newIndex),
|
||||
itemBuilder: (context, i) {
|
||||
final item = queue[i];
|
||||
final playing = item.id == currentId;
|
||||
@@ -58,7 +58,7 @@ class QueueScreen extends StatelessWidget {
|
||||
trailing: IconButton(
|
||||
tooltip: 'Aus Warteschlange entfernen',
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _remove(handler, i),
|
||||
onPressed: () => handler.removeQueueItemAt(i),
|
||||
),
|
||||
onTap: () => handler.skipToQueueItem(i),
|
||||
);
|
||||
@@ -70,68 +70,4 @@ 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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === 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);
|
||||
}
|
||||
|
||||
@@ -187,8 +187,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.wifi_off),
|
||||
title: const Text('📴 Offline-Modus'),
|
||||
subtitle: const Text('Nur gecachte Lieder abspielen'),
|
||||
title: const Text('Offline-Modus'),
|
||||
subtitle: const Text(
|
||||
'Titel vom Server nur abspielen, wenn sie schon hier liegen'),
|
||||
trailing: Consumer<OfflineMode>(
|
||||
builder: (context, offlineMode, _) {
|
||||
return Switch(
|
||||
|
||||
+24
-6
@@ -4,7 +4,14 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'theme.dart';
|
||||
|
||||
/// Cover-Bild aus einer file-URI, mit Noten-Platzhalter als Fallback.
|
||||
/// Cover-Bild aus einer Datei- oder Server-Adresse, mit Noten-Platzhalter
|
||||
/// als Fallback.
|
||||
///
|
||||
/// Der Server-Fall ist nicht nachträglich angeflanscht, sondern der Regelfall
|
||||
/// für jeden Titel aus Navidrome: dessen Cover liegt hinter `getCoverArt.view`
|
||||
/// und nicht als Datei auf dem Gerät. Vorher kannte diese Klasse nur `file://`
|
||||
/// — jeder Server-Titel war deshalb ein graues Notensymbol, in der Liste wie
|
||||
/// auf dem Sperrbildschirm.
|
||||
class CoverImage extends StatelessWidget {
|
||||
const CoverImage({
|
||||
super.key,
|
||||
@@ -35,16 +42,27 @@ class CoverImage extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
);
|
||||
Widget child = placeholder;
|
||||
if (artUri != null && artUri!.scheme == 'file') {
|
||||
child = Image.file(
|
||||
Widget child = switch (artUri?.scheme) {
|
||||
'file' => Image.file(
|
||||
File(artUri!.toFilePath()),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
);
|
||||
}
|
||||
),
|
||||
// Solange das Bild vom Server unterwegs ist, steht der Platzhalter da —
|
||||
// sonst springt die Zeile in der Liste, sobald es ankommt.
|
||||
'http' || 'https' => Image.network(
|
||||
artUri!.toString(),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => placeholder,
|
||||
frameBuilder: (_, kind, frame, warAusCache) =>
|
||||
frame == null && !warAusCache ? placeholder : kind,
|
||||
),
|
||||
_ => placeholder,
|
||||
};
|
||||
return ClipRRect(borderRadius: BorderRadius.circular(radius), child: 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>(
|
||||
|
||||
@@ -54,13 +54,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')),
|
||||
|
||||
@@ -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,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,82 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/library/database.dart';
|
||||
import 'package:melo/library/song_media.dart';
|
||||
import 'package:melo/player/audio_handler.dart';
|
||||
|
||||
Song _lokalerSong() => Song(
|
||||
id: 'uuid-1',
|
||||
path: '/storage/emulated/0/Music/Nightcore.mp3',
|
||||
title: 'No Friends',
|
||||
dateAddedMs: 0,
|
||||
updatedAtMs: 0,
|
||||
deleted: false,
|
||||
playCount: 0,
|
||||
categoriesEdited: false,
|
||||
metadataEdited: false,
|
||||
);
|
||||
|
||||
const _serverTitel = MediaItem(
|
||||
id: 'https://musik.example/rest/stream.view?id=abc123',
|
||||
title: 'Serverlied',
|
||||
extras: {'navidromeId': 'abc123'},
|
||||
);
|
||||
|
||||
/// Der Offline-Schalter in den Einstellungen war ein reines Anzeige-Element:
|
||||
/// gespeichert, aber von niemandem gelesen. Die App streamte weiter und
|
||||
/// verbrauchte Mobilfunkdaten, obwohl der Nutzer das Gegenteil eingestellt
|
||||
/// hatte.
|
||||
void main() {
|
||||
group('istOfflineVerfuegbar', () {
|
||||
test('ein Titel vom Gerät liegt immer als Datei vor', () {
|
||||
expect(
|
||||
istOfflineVerfuegbar(songToMediaItem(_lokalerSong()),
|
||||
cacheTreffer: false),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('ein Server-Titel mit Cache-Treffer läuft ohne Netz', () {
|
||||
expect(istOfflineVerfuegbar(_serverTitel, cacheTreffer: true), isTrue);
|
||||
});
|
||||
|
||||
test('ein Server-Titel ohne Cache-Treffer bräuchte das Netz', () {
|
||||
expect(istOfflineVerfuegbar(_serverTitel, cacheTreffer: false), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('startIndexNachFilter', () {
|
||||
test('bleibt stehen, wenn nichts vor dem Titel wegfällt', () {
|
||||
expect(startIndexNachFilter([true, true, true], 2), 2);
|
||||
});
|
||||
|
||||
test('rückt vor, wenn Titel davor wegfallen', () {
|
||||
// [x _ x X] → behalten sind 0,2,3; der gewählte Titel 3 ist jetzt 2.
|
||||
expect(startIndexNachFilter([true, false, true, true], 3), 2);
|
||||
});
|
||||
|
||||
test('fällt der gewählte Titel weg, rückt der nächste behaltene nach', () {
|
||||
// [x _ x] → der gewählte Titel 1 ist weg; an seine Stelle rückt Titel 2,
|
||||
// der in der gefilterten Liste den Index 1 hat.
|
||||
expect(startIndexNachFilter([true, false, true], 1), 1);
|
||||
});
|
||||
|
||||
test('fällt der letzte Titel weg, bleibt der davor', () {
|
||||
expect(startIndexNachFilter([true, true, false], 2), 1);
|
||||
});
|
||||
|
||||
test('bleibt bei leerer Auswahl bei 0', () {
|
||||
expect(startIndexNachFilter([false, false], 1), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('offlineHinweis', () {
|
||||
test('nennt einen einzelnen Titel', () {
|
||||
expect(offlineHinweis(1), contains('1 Titel übersprungen'));
|
||||
});
|
||||
|
||||
test('nennt die Anzahl bei mehreren Titeln', () {
|
||||
expect(offlineHinweis(7), contains('7 Titel übersprungen'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/player/audio_handler.dart';
|
||||
|
||||
MediaItem _item(String id) => MediaItem(id: id, title: 'Titel $id');
|
||||
|
||||
/// Entfernen und Umsortieren luden früher die ganze Warteschlange neu
|
||||
/// (`loadPlaylist`). Das riss die Wiedergabe hörbar auf und setzte dabei
|
||||
/// `_lastCountedIndex` zurück — der laufende Titel wurde jedes Mal erneut als
|
||||
/// Wiedergabe gezählt und verfälschte die Sortierung "Wie oft abgespielt".
|
||||
void main() {
|
||||
group('removeFromQueue', () {
|
||||
test('entfernt den Titel am angegebenen Index und verschiebt den Rest', () {
|
||||
final queue = [_item('a'), _item('b'), _item('c')];
|
||||
|
||||
expect(removeFromQueue(queue, 1).map((e) => e.id), ['a', 'c']);
|
||||
});
|
||||
|
||||
test('lässt die Original-Liste unverändert', () {
|
||||
final queue = [_item('a'), _item('b')];
|
||||
|
||||
removeFromQueue(queue, 0);
|
||||
|
||||
expect(queue.map((e) => e.id), ['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
group('reorderQueue', () {
|
||||
test('verschiebt einen Titel nach hinten', () {
|
||||
final queue = [_item('a'), _item('b'), _item('c')];
|
||||
|
||||
expect(reorderQueue(queue, 0, 2).map((e) => e.id), ['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
test('verschiebt einen Titel nach vorne', () {
|
||||
final queue = [_item('a'), _item('b'), _item('c')];
|
||||
|
||||
expect(reorderQueue(queue, 2, 0).map((e) => e.id), ['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('lässt die Original-Liste unverändert', () {
|
||||
final queue = [_item('a'), _item('b')];
|
||||
|
||||
reorderQueue(queue, 0, 1);
|
||||
|
||||
expect(queue.map((e) => e.id), ['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
group('gezaehlterIndexNachEntfernen', () {
|
||||
test('ein Titel vor dem gezählten rückt ihn eine Stelle nach vorn', () {
|
||||
// Sonst zeigt der gemerkte Index nach dem Entfernen auf den Nachbarn
|
||||
// und der laufende Titel wird ein zweites Mal gezählt.
|
||||
expect(gezaehlterIndexNachEntfernen(3, 1), 2);
|
||||
});
|
||||
|
||||
test('ein Titel hinter dem gezählten ändert nichts', () {
|
||||
expect(gezaehlterIndexNachEntfernen(1, 3), 1);
|
||||
});
|
||||
|
||||
test('wird der gezählte Titel selbst entfernt, gilt keiner mehr', () {
|
||||
expect(gezaehlterIndexNachEntfernen(2, 2), isNull);
|
||||
});
|
||||
|
||||
test('ohne gezählten Titel bleibt es dabei', () {
|
||||
expect(gezaehlterIndexNachEntfernen(null, 0), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('gezaehlterIndexNachVerschieben', () {
|
||||
test('der gezählte Titel selbst wandert mit', () {
|
||||
expect(gezaehlterIndexNachVerschieben(1, 1, 3), 3);
|
||||
});
|
||||
|
||||
test('ein Titel von vorn nach hinten rückt den gezählten nach vorn', () {
|
||||
// [a b C d] → b nach 3 → [a C d b]: C war 2, ist jetzt 1.
|
||||
expect(gezaehlterIndexNachVerschieben(2, 1, 3), 1);
|
||||
});
|
||||
|
||||
test('ein Titel von hinten nach vorn rückt den gezählten nach hinten', () {
|
||||
// [a B c d] → d nach 1 → [a d B c]: B war 1, ist jetzt 2.
|
||||
expect(gezaehlterIndexNachVerschieben(1, 3, 1), 2);
|
||||
});
|
||||
|
||||
test('eine Verschiebung ganz hinter dem gezählten ändert nichts', () {
|
||||
expect(gezaehlterIndexNachVerschieben(0, 2, 3), 0);
|
||||
});
|
||||
|
||||
test('eine Verschiebung ganz vor dem gezählten ändert nichts', () {
|
||||
expect(gezaehlterIndexNachVerschieben(3, 0, 1), 3);
|
||||
});
|
||||
|
||||
test('ohne gezählten Titel bleibt es dabei', () {
|
||||
expect(gezaehlterIndexNachVerschieben(null, 0, 1), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/player/queue_screen.dart';
|
||||
|
||||
MediaItem _item(String id) => MediaItem(id: id, title: 'Titel $id');
|
||||
|
||||
void main() {
|
||||
group('removeFromQueue', () {
|
||||
test('entfernt den Titel am angegebenen Index und verschiebt den Rest', () {
|
||||
final queue = [_item('a'), _item('b'), _item('c')];
|
||||
|
||||
final result = removeFromQueue(queue, 1);
|
||||
|
||||
expect(result.map((e) => e.id), ['a', 'c']);
|
||||
});
|
||||
|
||||
test('lässt die Original-Liste unverändert', () {
|
||||
final queue = [_item('a'), _item('b')];
|
||||
|
||||
removeFromQueue(queue, 0);
|
||||
|
||||
expect(queue.map((e) => e.id), ['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
group('reorderQueue', () {
|
||||
test('verschiebt einen Titel nach hinten', () {
|
||||
final queue = [_item('a'), _item('b'), _item('c')];
|
||||
|
||||
final result = reorderQueue(queue, 0, 2);
|
||||
|
||||
expect(result.map((e) => e.id), ['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
test('verschiebt einen Titel nach vorne', () {
|
||||
final queue = [_item('a'), _item('b'), _item('c')];
|
||||
|
||||
final result = reorderQueue(queue, 2, 0);
|
||||
|
||||
expect(result.map((e) => e.id), ['c', 'a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
group('newPlayingIndex', () {
|
||||
test('findet den abgespielten Titel an seiner neuen Position', () {
|
||||
final newQueue = [_item('b'), _item('c'), _item('a')];
|
||||
|
||||
final index = newPlayingIndex(newQueue, 'a', 0);
|
||||
|
||||
expect(index, 2);
|
||||
});
|
||||
|
||||
test('fällt auf fallbackIndex zurück, wenn der Titel entfernt wurde', () {
|
||||
// 'b' wurde entfernt (war der abgespielte Titel); an Index 1 steht jetzt 'c'.
|
||||
final newQueue = [_item('a'), _item('c')];
|
||||
|
||||
final index = newPlayingIndex(newQueue, 'b', 1);
|
||||
|
||||
expect(index, 1);
|
||||
});
|
||||
|
||||
test('begrenzt fallbackIndex auf die neue Listenlänge', () {
|
||||
// Letzter Titel der Liste wurde entfernt, fallbackIndex zeigt hinter das Ende.
|
||||
final newQueue = [_item('a')];
|
||||
|
||||
final index = newPlayingIndex(newQueue, 'b', 1);
|
||||
|
||||
expect(index, 0);
|
||||
});
|
||||
|
||||
test('gibt 0 zurück, wenn die neue Queue leer ist', () {
|
||||
final index = newPlayingIndex(const [], 'a', 0);
|
||||
|
||||
expect(index, 0);
|
||||
});
|
||||
|
||||
test('nutzt fallbackIndex, wenn playingId null ist', () {
|
||||
final newQueue = [_item('a'), _item('b')];
|
||||
|
||||
final index = newPlayingIndex(newQueue, null, 1);
|
||||
|
||||
expect(index, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/shared/cover.dart';
|
||||
|
||||
/// Server-Titel trugen nie ein Coverbild: [CoverImage] kannte nur `file://`
|
||||
/// und zeigte für alles andere den Noten-Platzhalter.
|
||||
void main() {
|
||||
Future<void> pumpe(WidgetTester tester, Uri? artUri) => tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(child: CoverImage(artUri: artUri, size: 48)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('ohne Adresse bleibt der Noten-Platzhalter', (tester) async {
|
||||
await pumpe(tester, null);
|
||||
|
||||
expect(find.byIcon(Icons.music_note), findsOneWidget);
|
||||
expect(find.byType(Image), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('eine Datei-Adresse wird als Bild geladen', (tester) async {
|
||||
await pumpe(tester, Uri.file('/tmp/gibtesnicht.jpg'));
|
||||
|
||||
expect(find.byType(Image), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('eine http-Adresse wird als Bild geladen', (tester) async {
|
||||
await pumpe(tester, Uri.parse('http://musik.example/getCoverArt.view?id=1'));
|
||||
|
||||
expect(find.byType(Image), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('eine https-Adresse wird als Bild geladen', (tester) async {
|
||||
await pumpe(
|
||||
tester, Uri.parse('https://musik.example/getCoverArt.view?id=1'));
|
||||
|
||||
expect(find.byType(Image), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ein unbekanntes Schema fällt auf den Platzhalter zurück',
|
||||
(tester) async {
|
||||
await pumpe(tester, Uri.parse('content://media/external/audio/42'));
|
||||
|
||||
expect(find.byIcon(Icons.music_note), findsOneWidget);
|
||||
expect(find.byType(Image), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:melo/library/database.dart';
|
||||
import 'package:melo/library/playlist_service.dart';
|
||||
import 'package:melo/shared/favorite_button.dart';
|
||||
|
||||
/// Ein Titel, der nur am Server liegt, hat keine UUID in der Bibliothek.
|
||||
/// Früher kam an dieser Stelle der leere String an: das Herz füllte sich,
|
||||
/// in den Favoriten (INNER JOIN auf songs) tauchte aber nie etwas auf.
|
||||
void main() {
|
||||
Future<void> pumpe(WidgetTester tester, MeloDb db, String songId) {
|
||||
return tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider<MeloDb>.value(value: db),
|
||||
ChangeNotifierProvider<PlaylistService>.value(
|
||||
value: PlaylistService(db)),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(body: FavoriteButton(songId: songId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('ohne Song-ID ist das Herz nicht bedienbar', (tester) async {
|
||||
final db = MeloDb(NativeDatabase.memory());
|
||||
await pumpe(tester, db, '');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final knopf = tester.widget<IconButton>(find.byType(IconButton));
|
||||
expect(knopf.onPressed, isNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
});
|
||||
|
||||
testWidgets('ohne Song-ID entsteht kein Favoriten-Eintrag', (tester) async {
|
||||
final db = MeloDb(NativeDatabase.memory());
|
||||
await pumpe(tester, db, '');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(IconButton));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(await db.select(db.favorites).get(), isEmpty);
|
||||
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
});
|
||||
|
||||
testWidgets('mit Song-ID bleibt das Herz bedienbar', (tester) async {
|
||||
final db = MeloDb(NativeDatabase.memory());
|
||||
await db.into(db.songs).insert(SongsCompanion.insert(
|
||||
id: 'song-1',
|
||||
path: '/a.mp3',
|
||||
title: 'A',
|
||||
dateAddedMs: 0,
|
||||
updatedAtMs: 0,
|
||||
));
|
||||
await pumpe(tester, db, 'song-1');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final knopf = tester.widget<IconButton>(find.byType(IconButton));
|
||||
expect(knopf.onPressed, isNotNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user