diff --git a/CHANGELOG.md b/CHANGELOG.md index 17836bd..d5c4f5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,60 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🎧 "Zuletzt" zeigte neu Hinzugefügtes statt zuletzt Gehörtem; Herz saß fern vom Songnamen (2026-08-29) + +- 🐛 **Der Schnellzugriff "Zuletzt" auf "Meine Musik" zeigte "zuletzt hinzugefügte" Songs, + nicht "zuletzt gehörte"** — von Tinker gemeldet. Sowohl die Karte als auch der + Vollbild-Screen dahinter nutzten `db.watchRecent()`, das nach `dateAddedMs` + (Hinzufüge-Datum) sortiert, nicht nach dem tatsächlichen Abspielen. +- 🔧 **Fix:** neue Methode `MeloDb.watchRecentlyPlayed()` (`database.dart`) wertet + stattdessen die `PlaybackHistory`-Tabelle aus — eine Subquery ermittelt je Song per + `GROUP BY songId` / `MAX(playedAtMs)` nur den jüngsten Play-Zeitpunkt, damit ein + mehrfach gehörter Song nur einmal erscheint (an der Position seines letzten + Abspielens), zurückverknüpft auf `songs` (ohne getombstonte). "Zuletzt" in + `my_music_screen.dart` (Karte + Vollbild-Screen, `RecentlyAddedScreen` dafür in + `RecentlyPlayedScreen` umbenannt) nutzt jetzt diese Methode. +- 🐛 **Das Favoriten-Herz im Vollbild-Player saß oben in der AppBar**, weit weg vom + Songnamen — ebenfalls von Tinker gemeldet. +- 🔧 **Fix:** `_Herz` aus der AppBar-`actions`-Row entfernt und stattdessen direkt + neben den Titel in `_Angaben` gesetzt (`now_playing_screen.dart`). +- ✅ **Getestet:** 3 neue Unit-Tests für `watchRecentlyPlayed()` (RED→GREEN: + Mehrfach-Play erscheint nur einmal an der neuesten Position, mehrere Songs + absteigend sortiert, getombstonter Song mit Historie fehlt) sowie ein neuer + Widget-Test, der bestätigt, dass das Herz nicht mehr in der AppBar, sondern in + derselben Row wie der Songtitel sitzt. `flutter analyze` ohne Befund. + +### 🔑 Auto-Anmeldung schlug beim App-Start fehl — betraf ALLE drei Nutzer (2026-08-29) + +- 🐛 **Die automatische Baka-Anmeldung klappte beim App-Start oft nicht** — + die App fragte dann nach dem "Baka-Konto", obwohl die Zugangsdaten + korrekt waren. Betraf **alle drei Nutzer** (Dustin, Baka, Tinker), nicht + nur einen — von Dustin selbst gemeldet und bei allen drei bestätigt. + Grund: `main.dart` baut die 5 Haupt-Tabs über `IndexedStack` alle sofort + beim ersten Frame, nicht erst beim Umschalten. Der Download-Tab und die + YT-Suche lösen dabei beide unabhängig voneinander denselben Auto-Login + aus (`pruefeServerUser()` → `BakaAuth.autoAnmelden()`) — praktisch + gleichzeitig, mit denselben Zugangsdaten. `autoAnmelden()` hatte keine + Schutzvorkehrung gegen so einen doppelten Aufruf (der `istAngemeldet`- + Check lief synchron, bevor die erste Anfrage zurückkam), also gingen + zwei fast-gleichzeitige Login-Anfragen an denselben Server raus — der + lehnte vermutlich eine davon ab (Session-/Lock-Konflikt). +- 🔧 **Fix:** `BakaAuth.autoAnmelden()` in `baka_auth.dart` hängt einen + überlappenden Aufruf jetzt an den bereits laufenden Login-Versuch an + (`_laufenderAutoLogin`), statt einen zweiten Request loszuschicken. +- 🔧 **Nebenbei zwei kleine Anmelde-Dialoge korrigiert:** Der Anmelde- + Dialog im Download-Tab (`downloads_screen.dart`) zeigte das + Benutzername-Feld immer mit "Baka" vorausgefüllt, egal wer die App + nutzt — zeigt jetzt den tatsächlich bekannten Navidrome-Benutzernamen + (leer, falls keiner bekannt). Der Navidrome-Dialog in den Einstellungen + (`settings_screen.dart`) wählte unter Baka/Tinker/Dustin immer "Baka" + vor — wählt jetzt den zuletzt bekannten Benutzer vor, falls bekannt. +- ✅ **Getestet:** neuer Unit-Test für `autoAnmelden()` (RED→GREEN, zwei + gleichzeitige Aufrufe lösen nur noch einen HTTP-Request aus statt + zwei) sowie ein neuer Widget-Test für die Benutzername-Vorbelegung im + Download-Tab-Dialog (RED→GREEN). Volle Suite grün, `flutter analyze` + ohne Befund. + ### ⬅️ Zurück-Geste auf Nicht-Start-Tabs minimierte die App (2026-08-27) - 🐛 **Auf "Favoriten" (oder einem anderen Tab außer "Meine Musik") tippen und diff --git a/lib/library/database.dart b/lib/library/database.dart index f3fc010..3068920 100644 --- a/lib/library/database.dart +++ b/lib/library/database.dart @@ -212,6 +212,35 @@ class MeloDb extends _$MeloDb { .watch(); } + /// Überwacht die zuletzt GEHÖRTEN Songs (nicht getombstonte), aus + /// [PlaybackHistory]. Ein mehrfach gespielter Song erscheint nur einmal — + /// an der Position seines jüngsten Abspielens. Dafür wird pro [songId] + /// zuerst der jüngste `playedAtMs`-Eintrag ermittelt (Subquery mit + /// `GROUP BY`), und darüber zurück auf [songs] verknüpft. + Stream> watchRecentlyPlayed({int limit = 50}) { + final letzterPlay = playbackHistory.playedAtMs.max(); + final letztePlaysProSong = selectOnly(playbackHistory) + ..addColumns([playbackHistory.songId, letzterPlay]) + ..groupBy([playbackHistory.songId]); + final subquery = Subquery(letztePlaysProSong, 'letzte_plays'); + final letzterPlayRef = subquery.ref(letzterPlay); + + final query = select(songs).join([ + innerJoin( + subquery, + subquery.ref(playbackHistory.songId).equalsExp(songs.id), + useColumns: false, + ), + ]) + ..where(songs.deleted.equals(false)) + ..orderBy([ + OrderingTerm(expression: letzterPlayRef, mode: OrderingMode.desc) + ]) + ..limit(limit); + + return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList()); + } + /// Alle Songs inkl. getombstonte. Intern für ID-Stabilität beim Scan. Future> allSongs() => select(songs).get(); diff --git a/lib/library/my_music_screen.dart b/lib/library/my_music_screen.dart index 684b4bb..452dee4 100644 --- a/lib/library/my_music_screen.dart +++ b/lib/library/my_music_screen.dart @@ -234,17 +234,17 @@ class _QuickAccessRow extends StatelessWidget { ), if (sektionen.contains(AppSettings.sektionZuletzt)) StreamBuilder>( - stream: db.watchRecent(limit: 50), + stream: db.watchRecentlyPlayed(limit: 50), builder: (context, snapshot) { final recent = snapshot.data ?? const []; return _QuickCard( icon: Icons.schedule, label: 'Zuletzt', - subtitle: 'Neu hinzugefügt', + subtitle: 'Zuletzt gehört', coverPath: recent.isEmpty ? null : recent.first.coverPath, onTap: () => Navigator.of(context).push( MaterialPageRoute( - builder: (_) => const RecentlyAddedScreen()), + builder: (_) => const RecentlyPlayedScreen()), ), ); }, @@ -363,22 +363,22 @@ class _Empty extends StatelessWidget { } } -/// Vollbild-Liste der zuletzt hinzugefügten Songs (Schnellzugriff "Zuletzt"). -class RecentlyAddedScreen extends StatelessWidget { - const RecentlyAddedScreen({super.key}); +/// Vollbild-Liste der zuletzt gehörten Songs (Schnellzugriff "Zuletzt"). +class RecentlyPlayedScreen extends StatelessWidget { + const RecentlyPlayedScreen({super.key}); @override Widget build(BuildContext context) { final db = context.read(); return Scaffold( - appBar: AppBar(title: const Text('Zuletzt hinzugefügt')), + appBar: AppBar(title: const Text('Zuletzt gehört')), body: StreamBuilder>( - stream: db.watchRecent(limit: 100), + stream: db.watchRecentlyPlayed(limit: 100), builder: (context, snapshot) { final songs = snapshot.data ?? const []; if (songs.isEmpty) { return const Center( - child: Text('Noch nichts hinzugefügt', + child: Text('Noch nichts abgespielt', style: TextStyle(color: MeloTheme.text2)), ); } diff --git a/lib/player/now_playing_screen.dart b/lib/player/now_playing_screen.dart index 3f961d0..21f9165 100644 --- a/lib/player/now_playing_screen.dart +++ b/lib/player/now_playing_screen.dart @@ -49,23 +49,14 @@ class NowPlayingScreen extends StatelessWidget { // mit der fragte die App den Server nach einem Titel namens // "https://…" und bekam nie einen Text. final songId = songIdOf(item) ?? navidromeIdOf(item) ?? ''; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: songId.isEmpty - ? 'Kein Songtext für diesen Titel verfügbar' - : 'Songtext', - icon: const Icon(Icons.lyrics), - onPressed: songId.isEmpty - ? null - : () => _showLyrics(context, songId), - ), - // Ein Titel der Bibliothek wird lokal favorisiert, einer - // vom Server am Server — beide bekommen dasselbe Herz, - // statt dass eines davon ausgegraut bleibt. - _Herz(item: item), - ], + return IconButton( + tooltip: songId.isEmpty + ? 'Kein Songtext für diesen Titel verfügbar' + : 'Songtext', + icon: const Icon(Icons.lyrics), + onPressed: songId.isEmpty + ? null + : () => _showLyrics(context, songId), ); }, ), @@ -240,12 +231,23 @@ class _Angaben extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - item.title, - style: Theme.of(context).textTheme.headlineSmall, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Text( + item.title, + style: Theme.of(context).textTheme.headlineSmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + // Ein Titel der Bibliothek wird lokal favorisiert, einer + // vom Server am Server — beide bekommen dasselbe Herz, + // statt dass eines davon ausgegraut bleibt. + _Herz(item: item), + ], ), const SizedBox(height: MeloSpace.sm), Text( diff --git a/test/library/recently_played_test.dart b/test/library/recently_played_test.dart new file mode 100644 index 0000000..e901658 --- /dev/null +++ b/test/library/recently_played_test.dart @@ -0,0 +1,73 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/database.dart'; + +Future _insertSong(MeloDb db, String id, {bool deleted = false}) { + return db.into(db.songs).insert(SongsCompanion.insert( + id: id, + path: '/$id.mp3', + title: id, + dateAddedMs: 0, + updatedAtMs: 0, + deleted: Value(deleted), + )); +} + +Future _play(MeloDb db, String songId, int playedAtMs) { + return db.into(db.playbackHistory).insert(PlaybackHistoryCompanion.insert( + songId: songId, + positionMs: 0, + playedAtMs: playedAtMs, + )); +} + +void main() { + test('ein mehrfach gespielter Song erscheint nur einmal, an der Position ' + 'seines jüngsten Abspielens', () async { + final db = MeloDb(NativeDatabase.memory()); + await _insertSong(db, 'a'); + await _insertSong(db, 'b'); + await _play(db, 'a', 1000); + await _play(db, 'b', 2000); + await _play(db, 'a', 3000); // a erneut gespielt, jetzt jüngster Play + + final result = await db.watchRecentlyPlayed().first; + + expect(result.map((s) => s.id).toList(), ['a', 'b']); + + await db.close(); + }); + + test('mehrere Songs erscheinen absteigend nach ihrem letzten Abspielen', + () async { + final db = MeloDb(NativeDatabase.memory()); + await _insertSong(db, 'a'); + await _insertSong(db, 'b'); + await _insertSong(db, 'c'); + await _play(db, 'a', 1000); + await _play(db, 'b', 3000); + await _play(db, 'c', 2000); + + final result = await db.watchRecentlyPlayed().first; + + expect(result.map((s) => s.id).toList(), ['b', 'c', 'a']); + + await db.close(); + }); + + test('ein gelöschter Song mit Wiedergabe-Historie erscheint nicht', + () async { + final db = MeloDb(NativeDatabase.memory()); + await _insertSong(db, 'a', deleted: true); + await _insertSong(db, 'b'); + await _play(db, 'a', 2000); + await _play(db, 'b', 1000); + + final result = await db.watchRecentlyPlayed().first; + + expect(result.map((s) => s.id).toList(), ['b']); + + await db.close(); + }); +} diff --git a/test/player/now_playing_heart_test.dart b/test/player/now_playing_heart_test.dart new file mode 100644 index 0000000..3e3b7a8 --- /dev/null +++ b/test/player/now_playing_heart_test.dart @@ -0,0 +1,155 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:drift/drift.dart' show Value, driftRuntimeOptions; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:melo/library/category_service.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/library/song_list.dart'; +import 'package:melo/player/audio_handler.dart'; +import 'package:melo/player/now_playing_screen.dart'; +import 'package:melo/settings/app_settings.dart'; +import 'package:melo/shared/favorite_button.dart'; + +/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff — +/// dieselbe Klasse wie in song_list_navigation_test.dart, dort erprobt. +class _FakeAudioHandler extends MeloAudioHandler { + _FakeAudioHandler(MeloDb db) : super(db: db); + + @override + Future loadPlaylist(List items, {int startIndex = 0}) async { + queue.add(items); + if (items.isNotEmpty) mediaItem.add(items[startIndex]); + return 0; + } +} + +void main() { + late MeloDb db; + late CategoryService kategorien; + late PlaylistService playlists; + late AppSettings einstellungen; + late _FakeAudioHandler handler; + + void stelleKanaeleRuhig() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => '/tmp/melo-test', + ); + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => null, + ); + } + + setUp(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + SharedPreferences.setMockInitialValues({}); + stelleKanaeleRuhig(); + db = MeloDb(NativeDatabase.memory()); + kategorien = CategoryService(db); + playlists = PlaylistService(db); + einstellungen = AppSettings(); + await einstellungen.init(); + handler = _FakeAudioHandler(db); + await db.into(db.songs).insert(SongsCompanion.insert( + id: 'song-1', + path: '/music/roses.mp3', + title: 'Roses', + artist: const Value('Neoni'), + dateAddedMs: 0, + updatedAtMs: 0, + )); + }); + + tearDown(() { + handler.dispose(); + kategorien.dispose(); + playlists.dispose(); + }); + + Song derSong() => Song( + id: 'song-1', + path: '/music/roses.mp3', + title: 'Roses', + artist: 'Neoni', + dateAddedMs: 0, + updatedAtMs: 0, + deleted: false, + playCount: 0, + categoriesEdited: false, + metadataEdited: false, + ); + + // Öffnet den Vollbild-Player über denselben Weg wie in + // song_list_navigation_test.dart (Tap auf eine SongZeile) — dieser Weg ist + // dort nachweislich unproblematisch, im Unterschied zu einem direkten + // Aufbau von NowPlayingScreen mit vorab befülltem MediaItem-Stream. + Future oeffnePlayer(WidgetTester tester) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: db), + Provider.value(value: handler), + ChangeNotifierProvider.value(value: kategorien), + ChangeNotifierProvider.value(value: playlists), + ChangeNotifierProvider.value(value: einstellungen), + ], + child: MaterialApp( + home: Scaffold( + body: SongZeile( + song: derSong(), + warteschlange: [derSong()], + index: 0, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Roses')); + await tester.pumpAndSettle(); + } + + Future raeumeAuf(WidgetTester tester) async { + // Ohne diesen Abschluss bleibt ein drift-Stream-Abo (Favoriten-Herz) + // beim Abbau des Baums als offener Fake-Timer stehen — dieselbe + // Aufräum-Zeile wie in song_list_navigation_test.dart. + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(milliseconds: 1)); + } + + testWidgets( + 'Herz sitzt neben dem Songnamen, nicht mehr in der AppBar', + (tester) async { + await oeffnePlayer(tester); + + expect(find.byType(NowPlayingScreen), findsOneWidget); + + // Nicht mehr in der AppBar: + final herzInAppBar = find.descendant( + of: find.byType(AppBar), + matching: find.byType(FavoriteButton), + ); + expect(herzInAppBar, findsNothing); + + // Aber sichtbar auf dem Bildschirm, in derselben Row wie der Titel. + final titelRow = find + .ancestor(of: find.text('Roses'), matching: find.byType(Row)) + .first; + final herzBeimTitel = find.descendant( + of: titelRow, + matching: find.byType(FavoriteButton), + ); + expect(herzBeimTitel, findsOneWidget); + + await raeumeAuf(tester); + }); +}