diff --git a/.ui-design/reviews/melo_vollwertigkeit_20260823.md b/.ui-design/reviews/melo_vollwertigkeit_20260823.md
new file mode 100644
index 0000000..a319c4b
--- /dev/null
+++ b/.ui-design/reviews/melo_vollwertigkeit_20260823.md
@@ -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._
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cafbcd5..f7429e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index aa53461..91d88fd 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -9,6 +9,10 @@
+
+
diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart
index 8471d8f..136c923 100644
--- a/lib/downloads/downloads_screen.dart
+++ b/lib/downloads/downloads_screen.dart
@@ -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(
diff --git a/lib/library/permissions.dart b/lib/library/permissions.dart
index 97ad1de..caf6dea 100644
--- a/lib/library/permissions.dart
+++ b/lib/library/permissions.dart
@@ -19,3 +19,30 @@ Future ensureAudioPermission() async {
/// Öffnet die System-Einstellungen der App (für dauerhaft verweigerte Rechte).
Future 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 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;
+}
diff --git a/lib/library/song_list.dart b/lib/library/song_list.dart
index ffb8ec2..6ccdfcf 100644
--- a/lib/library/song_list.dart
+++ b/lib/library/song_list.dart
@@ -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 _showMenu(BuildContext context, Song song) async {
final handler = context.read();
final categories = context.read();
@@ -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')),
+ );
}
},
);
diff --git a/lib/library/song_media.dart b/lib/library/song_media.dart
index d3724db..b49ce7e 100644
--- a/lib/library/song_media.dart
+++ b/lib/library/song_media.dart
@@ -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 playSongs(
+/// anzuzeigende Cover (siehe [songToMediaItem]). Gibt zurück, wie viele Titel
+/// der Offline-Modus übersprungen hat.
+Future playSongs(
MeloAudioHandler handler,
List songs,
int startIndex, {
diff --git a/lib/main.dart b/lib/main.dart
index 7e2857a..beadad8 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -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 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 with WidgetsBindingObserver {
// Beim Start einmal mit dem Server abgleichen — neue Titel von anderen
// Geräten sind dann sofort da.
context.read().automatisch();
+ unawaited(_frageBenachrichtigungsRecht(context.read()));
+ }
+
+ /// 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 _frageBenachrichtigungsRecht(MeloAudioHandler handler) async {
+ try {
+ await handler.mediaItem.firstWhere((item) => item != null);
+ await ensureNotificationPermission();
+ } catch (e) {
+ logger.warning('Benachrichtigungs-Recht nicht erfragt: $e');
+ }
}
@override
diff --git a/lib/player/audio_handler.dart b/lib/player/audio_handler.dart
index 4516213..eeaa606 100644
--- a/lib/player/audio_handler.dart
+++ b/lib/player/audio_handler.dart
@@ -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 removeFromQueue(List queue, int removeIndex) {
+ return List.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 reorderQueue(List queue, int oldIndex, int newIndex) {
+ final result = List.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 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 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 loadPlaylist(List 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 loadPlaylist(List 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 = [];
+ final sources = [];
+ 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 = [];
- 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 _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 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 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 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 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 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 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
diff --git a/lib/player/queue_screen.dart b/lib/player/queue_screen.dart
index 5c5d0e1..b15e71e 100644
--- a/lib/player/queue_screen.dart
+++ b/lib/player/queue_screen.dart
@@ -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 _remove(MeloAudioHandler handler, int index) async {
- final queue = handler.queue.valueOrNull ?? const [];
- 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 _reorder(
- MeloAudioHandler handler, int oldIndex, int newIndex) async {
- final queue = handler.queue.valueOrNull ?? const [];
- 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 removeFromQueue(List queue, int removeIndex) {
- return List.from(queue)..removeAt(removeIndex);
-}
-
-/// Verschiebt den Titel von [oldIndex] nach [newIndex]. `newIndex` ist bereits
-/// um das entfernte Element bereinigt (siehe `ReorderableListView.onReorderItem`).
-List reorderQueue(List queue, int oldIndex, int newIndex) {
- final result = List.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 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);
}
diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart
index 68f1498..61b52c2 100644
--- a/lib/settings/settings_screen.dart
+++ b/lib/settings/settings_screen.dart
@@ -187,8 +187,9 @@ class _SettingsScreenState extends State {
),
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(
builder: (context, offlineMode, _) {
return Switch(
diff --git a/lib/shared/cover.dart b/lib/shared/cover.dart
index 8c23bd3..1429773 100644
--- a/lib/shared/cover.dart
+++ b/lib/shared/cover.dart
@@ -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(
- File(artUri!.toFilePath()),
- width: size,
- height: size,
- fit: BoxFit.cover,
- errorBuilder: (_, _, _) => placeholder,
- );
- }
+ Widget child = switch (artUri?.scheme) {
+ 'file' => Image.file(
+ File(artUri!.toFilePath()),
+ width: size,
+ height: size,
+ fit: BoxFit.cover,
+ errorBuilder: (_, _, _) => placeholder,
+ ),
+ // 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);
}
}
diff --git a/lib/shared/favorite_button.dart b/lib/shared/favorite_button.dart
index 39f6061..d8c5768 100644
--- a/lib/shared/favorite_button.dart
+++ b/lib/shared/favorite_button.dart
@@ -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();
final service = context.read();
return StreamBuilder(
diff --git a/lib/shared/sortable_song_list.dart b/lib/shared/sortable_song_list.dart
index 36cd495..25317d2 100644
--- a/lib/shared/sortable_song_list.dart
+++ b/lib/shared/sortable_song_list.dart
@@ -54,13 +54,18 @@ class _SortableSongListState extends State {
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')),
diff --git a/test/library/benachrichtigungs_recht_test.dart b/test/library/benachrichtigungs_recht_test.dart
new file mode 100644
index 0000000..ff3eb5d
--- /dev/null
+++ b/test/library/benachrichtigungs_recht_test.dart
@@ -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,
+ );
+ });
+ });
+}
diff --git a/test/library/subsonic_media_item_test.dart b/test/library/subsonic_media_item_test.dart
new file mode 100644
index 0000000..3e56ffd
--- /dev/null
+++ b/test/library/subsonic_media_item_test.dart
@@ -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);
+ });
+}
diff --git a/test/player/offline_filter_test.dart b/test/player/offline_filter_test.dart
new file mode 100644
index 0000000..118e494
--- /dev/null
+++ b/test/player/offline_filter_test.dart
@@ -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'));
+ });
+ });
+}
diff --git a/test/player/queue_mutation_test.dart b/test/player/queue_mutation_test.dart
new file mode 100644
index 0000000..1347fea
--- /dev/null
+++ b/test/player/queue_mutation_test.dart
@@ -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);
+ });
+ });
+}
diff --git a/test/player/queue_screen_test.dart b/test/player/queue_screen_test.dart
deleted file mode 100644
index 0eb9272..0000000
--- a/test/player/queue_screen_test.dart
+++ /dev/null
@@ -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);
- });
- });
-}
diff --git a/test/shared/cover_test.dart b/test/shared/cover_test.dart
new file mode 100644
index 0000000..618497d
--- /dev/null
+++ b/test/shared/cover_test.dart
@@ -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 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);
+ });
+}
diff --git a/test/shared/favorite_button_ohne_id_test.dart b/test/shared/favorite_button_ohne_id_test.dart
new file mode 100644
index 0000000..fd03ab2
--- /dev/null
+++ b/test/shared/favorite_button_ohne_id_test.dart
@@ -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 pumpe(WidgetTester tester, MeloDb db, String songId) {
+ return tester.pumpWidget(
+ MultiProvider(
+ providers: [
+ Provider.value(value: db),
+ ChangeNotifierProvider.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(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(find.byType(IconButton));
+ expect(knopf.onPressed, isNotNull);
+
+ await tester.pumpWidget(const SizedBox());
+ await tester.pump(const Duration(milliseconds: 1));
+ });
+}