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)); + }); +}