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:
Hermes (Server)
2026-08-23 18:34:26 +02:00
co-authored by Claude Opus 5
parent 34d6d8e52a
commit e6d5f1c9ee
21 changed files with 1222 additions and 232 deletions
@@ -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 (K1K5) 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._
+87
View File
@@ -5,6 +5,93 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [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) ### 🏷️ Der zweite Tab heißt wieder „Download" (2026-08-21)
- 📥 **Aus „Online" wird „Download".** Der Bereich behält seinen Inhalt - 📥 **Aus „Online" wird „Download".** Der Bereich behält seinen Inhalt
+4
View File
@@ -9,6 +9,10 @@
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- Equalizer/Bass/Virtualizer (android.media.audiofx) --> <!-- Equalizer/Bass/Virtualizer (android.media.audiofx) -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <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. --> <!-- Musikerkennung: kurze Aufnahme ueber das Mikrofon fuer ACRCloud. -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
<!-- Musik lesen: Android 13+ (API 33) nutzt READ_MEDIA_AUDIO, ältere READ_EXTERNAL_STORAGE. --> <!-- Musik lesen: Android 13+ (API 33) nutzt READ_MEDIA_AUDIO, ältere READ_EXTERNAL_STORAGE. -->
+27 -27
View File
@@ -1,6 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.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/category_service.dart';
import '../library/database.dart'; import '../library/database.dart';
import '../library/library_service.dart'; import '../library/library_service.dart';
import '../library/song_media.dart';
import '../player/audio_handler.dart'; import '../player/audio_handler.dart';
import '../services/baka_auth.dart'; import '../services/baka_auth.dart';
import '../services/media_store.dart'; import '../services/media_store.dart';
@@ -306,19 +306,20 @@ class _ServerBrowserState extends State<_ServerBrowser> {
); );
return; return;
} }
final items = songs final items = [for (final s in songs) subsonicToMediaItem(s, _nav)];
.map((s) => MediaItem( final uebersprungen = await handler.loadPlaylist(items);
id: _nav.streamUrl(s.id).toString(), if (mounted && uebersprungen > 0) {
title: s.titel, ScaffoldMessenger.of(context).showSnackBar(
artist: s.kuenstler, SnackBar(content: Text(offlineHinweis(uebersprungen))),
album: s.album, );
duration: Duration(seconds: s.dauerSekunden), }
// Die Server-ID getrennt von der Abspiel-URI mitgeben: nur damit } on NichtsOfflineVerfuegbar catch (e) {
// erkennt der Player einen Server-Titel (Cache, Scrobble, Bookmark). // Kein Fehler, sondern die gewählte Einstellung — deshalb ohne
extras: {'navidromeId': s.id}, // "Fehler:" davor, sonst sucht der Nutzer eine Störung.
)) if (!mounted) return;
.toList(); ScaffoldMessenger.of(context).showSnackBar(
await handler.loadPlaylist(items); SnackBar(content: Text('$e')),
);
} catch (e) { } catch (e) {
if (!mounted) { if (!mounted) {
return; return;
@@ -341,19 +342,18 @@ class _ServerBrowserState extends State<_ServerBrowser> {
); );
return; return;
} }
final items = songs final items = [for (final s in songs) subsonicToMediaItem(s, _nav)];
.map((s) => MediaItem( final uebersprungen = await handler.loadPlaylist(items);
id: _nav.streamUrl(s.id).toString(), if (mounted && uebersprungen > 0) {
title: s.titel, ScaffoldMessenger.of(context).showSnackBar(
artist: s.kuenstler, SnackBar(content: Text(offlineHinweis(uebersprungen))),
album: s.album, );
duration: Duration(seconds: s.dauerSekunden), }
// Die Server-ID getrennt von der Abspiel-URI mitgeben: nur damit } on NichtsOfflineVerfuegbar catch (e) {
// erkennt der Player einen Server-Titel (Cache, Scrobble, Bookmark). if (!mounted) return;
extras: {'navidromeId': s.id}, ScaffoldMessenger.of(context).showSnackBar(
)) SnackBar(content: Text('$e')),
.toList(); );
await handler.loadPlaylist(items);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
+27
View File
@@ -19,3 +19,30 @@ Future<bool> ensureAudioPermission() async {
/// Öffnet die System-Einstellungen der App (für dauerhaft verweigerte Rechte). /// Öffnet die System-Einstellungen der App (für dauerhaft verweigerte Rechte).
Future<void> openMusicPermissionSettings() => openAppSettings(); 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
View File
@@ -83,6 +83,11 @@ class SongList extends StatelessWidget {
} }
/// Menü hinter dem Drei-Punkte-Symbol einer Songzeile. /// 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 { Future<void> _showMenu(BuildContext context, Song song) async {
final handler = context.read<MeloAudioHandler>(); final handler = context.read<MeloAudioHandler>();
final categories = context.read<CategoryService>(); final categories = context.read<CategoryService>();
@@ -112,9 +117,11 @@ class SongList extends StatelessWidget {
title: const Text('Als Nächstes spielen'), title: const Text('Als Nächstes spielen'),
onTap: () async { onTap: () async {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
await handler.playNext(item()); final ok = await handler.playNext(item());
messenger.showSnackBar(const SnackBar( messenger.showSnackBar(SnackBar(
content: Text('Läuft als Nächstes'))); content: Text(ok
? 'Läuft als Nächstes'
: nichtOfflineHinweis)));
}, },
), ),
ListTile( ListTile(
@@ -124,9 +131,11 @@ class SongList extends StatelessWidget {
'Nur für jetzt — nicht in Favoriten oder Wiedergabelisten'), 'Nur für jetzt — nicht in Favoriten oder Wiedergabelisten'),
onTap: () async { onTap: () async {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
await handler.addToQueue(item()); final ok = await handler.addToQueue(item());
messenger.showSnackBar(const SnackBar( messenger.showSnackBar(SnackBar(
content: Text('Zur Warteschlange hinzugefügt'))); content: Text(ok
? 'Zur Warteschlange hinzugefügt'
: nichtOfflineHinweis)));
}, },
), ),
ListTile( ListTile(
@@ -178,20 +187,24 @@ class SongList extends StatelessWidget {
], ],
), ),
onTap: () async { onTap: () async {
final messenger = ScaffoldMessenger.of(context);
try { try {
await playSongs( final uebersprungen = await playSongs(
handler, handler,
songs, songs,
i, i,
coverOf: (song) => categories.coverFor(song, coverOf: (song) => categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory), groupByCategory: settings.groupCoversByCategory),
); );
} catch (e) { if (uebersprungen > 0) {
if (context.mounted) { messenger.showSnackBar(
ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(offlineHinweis(uebersprungen))),
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
); );
} }
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
);
} }
}, },
); );
+36 -2
View File
@@ -1,6 +1,7 @@
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import '../player/audio_handler.dart'; import '../player/audio_handler.dart';
import '../services/navidrome_service.dart';
import 'database.dart'; import 'database.dart';
/// [cover] überschreibt das Coverbild des Songs — damit auf Sperrbildschirm /// [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 /// Spielt [songs] ab [startIndex] ab. [coverOf] liefert je Song das
/// anzuzeigende Cover (siehe [songToMediaItem]). /// anzuzeigende Cover (siehe [songToMediaItem]). Gibt zurück, wie viele Titel
Future<void> playSongs( /// der Offline-Modus übersprungen hat.
Future<int> playSongs(
MeloAudioHandler handler, MeloAudioHandler handler,
List<Song> songs, List<Song> songs,
int startIndex, { int startIndex, {
+26
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
@@ -12,6 +13,7 @@ import 'library/database.dart';
import 'library/favorites_screen.dart'; import 'library/favorites_screen.dart';
import 'library/library_service.dart'; import 'library/library_service.dart';
import 'library/my_music_screen.dart'; import 'library/my_music_screen.dart';
import 'library/permissions.dart';
import 'library/playlist_service.dart'; import 'library/playlist_service.dart';
import 'library/search_screen.dart'; import 'library/search_screen.dart';
import 'player/audio_effects.dart'; import 'player/audio_effects.dart';
@@ -81,6 +83,12 @@ Future<void> main() async {
uebernehmeEinstellungen(); uebernehmeEinstellungen();
_settings.addListener(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()); 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 // Beim Start einmal mit dem Server abgleichen — neue Titel von anderen
// Geräten sind dann sofort da. // Geräten sind dann sofort da.
context.read<SyncService>().automatisch(); 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 @override
+178 -24
View File
@@ -36,6 +36,43 @@ int playNextIndex({required int? currentIndex, required int queueLength}) {
return (currentIndex + 1).clamp(0, 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 /// Navidrome-Song-ID eines Warteschlangen-Eintrags, oder `null` bei einem
/// Titel vom Gerät. /// Titel vom Gerät.
/// ///
@@ -60,6 +97,34 @@ bool nutztServerCache(MediaItem item) => navidromeIdOf(item) != null;
Uri quelleFuer(MediaItem item, {String? cacheTreffer}) => Uri quelleFuer(MediaItem item, {String? cacheTreffer}) =>
cacheTreffer != null ? Uri.file(cacheTreffer) : Uri.parse(item.id); 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, /// Kern der Wiedergabe: kapselt just_audio hinter audio_service,
/// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren. /// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren.
class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
@@ -79,6 +144,10 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
/// Wird von den App-Einstellungen gesetzt. /// Wird von den App-Einstellungen gesetzt.
bool normalizeVolume = true; 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}) { MeloAudioHandler({required this.db}) {
_cache = CacheManager(); _cache = CacheManager();
_cache.init(); _cache.init();
@@ -159,40 +228,51 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
Stream<int?> get androidAudioSessionIdStream => Stream<int?> get androidAudioSessionIdStream =>
_player.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 /// Baut die Quellen ohne einen einzigen Netzzugriff: [MediaItem.id] ist
/// bereits abspielbar. Früher lud diese Schleife die *ganze* Warteschlange /// bereits abspielbar. Früher lud diese Schleife die *ganze* Warteschlange
/// vorab vom Server herunter — bei einer Bibliothek mit hunderten Titeln /// vorab vom Server herunter — bei einer Bibliothek mit hunderten Titeln
/// begann die Wiedergabe deshalb nie, und ein zweiter Tipp brach den /// begann die Wiedergabe deshalb nie, und ein zweiter Tipp brach den
/// laufenden Ladevorgang ab ("Loading interrupted"). /// 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 // Neue Warteschlange: der erste Titel soll wieder zählen, auch wenn er
// denselben Index wie der zuletzt gezählte hat. // denselben Index wie der zuletzt gezählte hat.
_lastCountedIndex = null; _lastCountedIndex = null;
final sources = <AudioSource>[]; await _player.setAudioSources(sources, initialIndex: start);
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);
// Bei bekannter letzter Position an dieser Stelle fortsetzen, // Bei bekannter letzter Position an dieser Stelle fortsetzen,
// statt immer von vorne zu beginnen. // statt immer von vorne zu beginnen.
final item = items[startIndex]; final item = gefiltert[start];
final resumeMs = await _fortsetzPosition(item); final resumeMs = await _fortsetzPosition(item);
if (resumeMs != null) { if (resumeMs != null) {
await _player.seek(Duration(milliseconds: resumeMs), index: startIndex); await _player.seek(Duration(milliseconds: resumeMs), index: start);
} }
await play(); await play();
@@ -202,6 +282,28 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
if (navId != null) { if (navId != null) {
unawaited(_nav.ladeInCache(navId, Uri.parse(item.id), _cache)); 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. /// 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 /// Hängt [item] hinten an die Warteschlange an, ohne die laufende
/// Wiedergabe zu unterbrechen. Favoriten und Wiedergabelisten bleiben /// Wiedergabe zu unterbrechen. Favoriten und Wiedergabelisten bleiben
/// unberührt — die Warteschlange ist nur für diese Sitzung. /// 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]); queue.add([...queue.value, item]);
return true;
} }
/// Spielt [item] als Nächstes, direkt nach dem laufenden Titel. /// 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( final index = playNextIndex(
currentIndex: _player.currentIndex, currentIndex: _player.currentIndex,
queueLength: queue.value.length, queueLength: queue.value.length,
); );
await _player.insertAudioSource( await _player.insertAudioSource(index, quelle);
index, AudioSource.uri(Uri.parse(item.id), tag: item));
final updated = [...queue.value]..insert(index, item); final updated = [...queue.value]..insert(index, item);
queue.add(updated); 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 @override
+5 -69
View File
@@ -9,9 +9,9 @@ import 'audio_handler.dart';
/// Warteschlangen-Ansicht: aktuelle Queue anzeigen, zu einem Titel springen, /// Warteschlangen-Ansicht: aktuelle Queue anzeigen, zu einem Titel springen,
/// Titel entfernen oder per Drag & Drop umsortieren. /// Titel entfernen oder per Drag & Drop umsortieren.
/// ///
/// Der `MeloAudioHandler` bietet keine Live-Queue-Mutation (kein /// Der `MeloAudioHandler` mutiert die Warteschlange direkt
/// `removeQueueItem`/`moveQueueItem`), daher wird die Queue clientseitig neu /// ([MeloAudioHandler.removeQueueItemAt], [MeloAudioHandler.moveQueueItem]) —
/// berechnet und per `loadPlaylist` neu geladen. /// die laufende Wiedergabe bleibt dabei unangetastet.
class QueueScreen extends StatelessWidget { class QueueScreen extends StatelessWidget {
const QueueScreen({super.key}); const QueueScreen({super.key});
@@ -37,7 +37,7 @@ class QueueScreen extends StatelessWidget {
return ReorderableListView.builder( return ReorderableListView.builder(
itemCount: queue.length, itemCount: queue.length,
onReorderItem: (oldIndex, newIndex) => onReorderItem: (oldIndex, newIndex) =>
_reorder(handler, oldIndex, newIndex), handler.moveQueueItem(oldIndex, newIndex),
itemBuilder: (context, i) { itemBuilder: (context, i) {
final item = queue[i]; final item = queue[i];
final playing = item.id == currentId; final playing = item.id == currentId;
@@ -58,7 +58,7 @@ class QueueScreen extends StatelessWidget {
trailing: IconButton( trailing: IconButton(
tooltip: 'Aus Warteschlange entfernen', tooltip: 'Aus Warteschlange entfernen',
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () => _remove(handler, i), onPressed: () => handler.removeQueueItemAt(i),
), ),
onTap: () => handler.skipToQueueItem(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);
} }
+3 -2
View File
@@ -187,8 +187,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.wifi_off), leading: const Icon(Icons.wifi_off),
title: const Text('📴 Offline-Modus'), title: const Text('Offline-Modus'),
subtitle: const Text('Nur gecachte Lieder abspielen'), subtitle: const Text(
'Titel vom Server nur abspielen, wenn sie schon hier liegen'),
trailing: Consumer<OfflineMode>( trailing: Consumer<OfflineMode>(
builder: (context, offlineMode, _) { builder: (context, offlineMode, _) {
return Switch( return Switch(
+24 -6
View File
@@ -4,7 +4,14 @@ import 'package:flutter/material.dart';
import 'theme.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 { class CoverImage extends StatelessWidget {
const CoverImage({ const CoverImage({
super.key, super.key,
@@ -35,16 +42,27 @@ class CoverImage extends StatelessWidget {
}, },
), ),
); );
Widget child = placeholder; Widget child = switch (artUri?.scheme) {
if (artUri != null && artUri!.scheme == 'file') { 'file' => Image.file(
child = Image.file(
File(artUri!.toFilePath()), File(artUri!.toFilePath()),
width: size, width: size,
height: size, height: size,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => placeholder, 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); return ClipRRect(borderRadius: BorderRadius.circular(radius), child: child);
} }
} }
+12
View File
@@ -5,12 +5,24 @@ import '../library/playlist_service.dart';
import 'theme.dart'; import 'theme.dart';
/// Herz-Toggle: ausgefüllt = favorisiert, umrandet = nicht favorisiert. /// 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 { class FavoriteButton extends StatelessWidget {
const FavoriteButton({super.key, required this.songId}); const FavoriteButton({super.key, required this.songId});
final String songId; final String songId;
@override @override
Widget build(BuildContext context) { 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 db = context.read<MeloDb>();
final service = context.read<PlaylistService>(); final service = context.read<PlaylistService>();
return StreamBuilder<bool>( return StreamBuilder<bool>(
+6 -1
View File
@@ -54,13 +54,18 @@ class _SortableSongListState extends State<SortableSongList> {
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final shuffled = [...widget.songs]..shuffle(); final shuffled = [...widget.songs]..shuffle();
try { try {
await playSongs( final uebersprungen = await playSongs(
handler, handler,
shuffled, shuffled,
0, 0,
coverOf: (song) => categories.coverFor(song, coverOf: (song) => categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory), groupByCategory: settings.groupCoversByCategory),
); );
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
);
}
} catch (e) { } catch (e) {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')), 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);
});
}
+82
View File
@@ -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'));
});
});
}
+97
View File
@@ -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);
});
});
}
-85
View File
@@ -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);
});
});
}
+49
View File
@@ -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));
});
}