From 7dbea128e05e49dc95caeff0297d5abc3f076552 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Thu, 27 Aug 2026 14:49:32 +0200 Subject: [PATCH 01/10] Shuffle/Wiederholung: PlaybackState meldet echten Zustand statt immer "aus" Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP --- lib/player/audio_handler.dart | 18 ++++++++++++++ test/player/audio_handler_pure_test.dart | 30 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 test/player/audio_handler_pure_test.dart diff --git a/lib/player/audio_handler.dart b/lib/player/audio_handler.dart index bff047b..1598660 100644 --- a/lib/player/audio_handler.dart +++ b/lib/player/audio_handler.dart @@ -130,6 +130,22 @@ int startIndexNachFilter(List behalten, int startIndex) { return davor.clamp(0, gesamt - 1); } +/// Übersetzung des Player-Shuffle-Zustands in den `audio_service`-Wert für +/// `PlaybackState.shuffleMode`. Als reine Funktion ausgelagert, damit sie +/// ohne laufenden `AudioPlayer` testbar ist — `_transformEvent()` läuft bei +/// jedem Player-Event und lässt sich nicht isoliert aufrufen. +AudioServiceShuffleMode toAudioServiceShuffleMode(bool enabled) => + enabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none; + +/// Übersetzung des Player-Loop-Modus in den `audio_service`-Wert für +/// `PlaybackState.repeatMode`. Siehe [toAudioServiceShuffleMode]. +AudioServiceRepeatMode toAudioServiceRepeatMode(LoopMode loopMode) => + switch (loopMode) { + LoopMode.one => AudioServiceRepeatMode.one, + LoopMode.all => AudioServiceRepeatMode.all, + LoopMode.off => AudioServiceRepeatMode.none, + }; + /// Im Offline-Modus ist keiner der gewählten Titel ohne Netz abspielbar. class NichtsOfflineVerfuegbar implements Exception { const NichtsOfflineVerfuegbar(); @@ -633,6 +649,8 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { }[_player.processingState] ?? AudioProcessingState.idle, playing: _player.playing, + shuffleMode: toAudioServiceShuffleMode(_player.shuffleModeEnabled), + repeatMode: toAudioServiceRepeatMode(_player.loopMode), updatePosition: _player.position, bufferedPosition: _player.bufferedPosition, speed: _player.speed, diff --git a/test/player/audio_handler_pure_test.dart b/test/player/audio_handler_pure_test.dart new file mode 100644 index 0000000..8ac1b15 --- /dev/null +++ b/test/player/audio_handler_pure_test.dart @@ -0,0 +1,30 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:melo/player/audio_handler.dart'; + +void main() { + group('toAudioServiceShuffleMode', () { + test('true wird zu AudioServiceShuffleMode.all', () { + expect(toAudioServiceShuffleMode(true), AudioServiceShuffleMode.all); + }); + + test('false wird zu AudioServiceShuffleMode.none', () { + expect(toAudioServiceShuffleMode(false), AudioServiceShuffleMode.none); + }); + }); + + group('toAudioServiceRepeatMode', () { + test('LoopMode.one wird zu AudioServiceRepeatMode.one', () { + expect(toAudioServiceRepeatMode(LoopMode.one), AudioServiceRepeatMode.one); + }); + + test('LoopMode.all wird zu AudioServiceRepeatMode.all', () { + expect(toAudioServiceRepeatMode(LoopMode.all), AudioServiceRepeatMode.all); + }); + + test('LoopMode.off wird zu AudioServiceRepeatMode.none', () { + expect(toAudioServiceRepeatMode(LoopMode.off), AudioServiceRepeatMode.none); + }); + }); +} -- 2.54.0 From 648b22bfa700c49506a68e3a3dde72f78e404d74 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Thu, 27 Aug 2026 14:53:59 +0200 Subject: [PATCH 02/10] AudioSession konfigurieren: Audio-Fokus bleibt beim automatischen Songwechsel erhalten --- lib/player/audio_handler.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/player/audio_handler.dart b/lib/player/audio_handler.dart index 1598660..0e47272 100644 --- a/lib/player/audio_handler.dart +++ b/lib/player/audio_handler.dart @@ -267,6 +267,7 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { /// das in den Einstellungen erlaubt hat. Future _watchHeadphones() async { final session = await AudioSession.instance; + await session.configure(const AudioSessionConfiguration.music()); session.devicesChangedEventStream.listen((event) { final added = event.devicesAdded.map((d) => d.type).toSet(); if (shouldAutoPlay( -- 2.54.0 From 94a48675b1aca7830460617f31941daf0ee8b697 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Thu, 27 Aug 2026 15:10:33 +0200 Subject: [PATCH 03/10] Song-Tap oeffnet den Vollbild-Player mit grossem Cover --- CHANGELOG.md | 23 ++++ lib/downloads/server_titel_screen.dart | 5 + lib/library/song_list.dart | 5 + lib/playlists/playlist_detail_screen.dart | 5 + .../server_titel_navigation_test.dart | 92 +++++++++++++ test/library/song_list_navigation_test.dart | 128 ++++++++++++++++++ .../playlist_detail_navigation_test.dart | 121 +++++++++++++++++ 7 files changed, 379 insertions(+) create mode 100644 test/downloads/server_titel_navigation_test.dart create mode 100644 test/library/song_list_navigation_test.dart create mode 100644 test/playlists/playlist_detail_navigation_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 739cd5f..9d6c055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🎧 Song antippen öffnet den Vollbild-Player (2026-08-27) + +- 🐛 **Ein Song antippen öffnete früher nur die Wiedergabe, ohne zum + Vollbild-Player mit großem Cover zu wechseln** — man landete weiter auf + der Bibliotheks-/Playlist-/Server-Liste und musste erst den Mini-Player + antippen oder hochwischen, um das große Cover zu sehen. Behoben an den + drei Stellen, an denen ein Song EXPLIZIT zum Abspielen angetippt wird: + Bibliothek (`song_list.dart`), Wiedergabeliste + (`playlist_detail_screen.dart`) und Server-Titel + (`server_titel_screen.dart`, dort auch für die Knöpfe "Abspielen" und + "Zufällig", weil sie über dieselbe zentrale `_spiele`-Methode laufen). + Nutzt dasselbe, im Projekt etablierte Navigationsmuster wie der + Mini-Player (`Navigator.push(MaterialPageRoute(builder: (_) => + NowPlayingScreen()))`), nach erfolgreicher Wiedergabe und vor einer + eventuellen Offline-Snackbar, damit die Meldung auf dem neuen Screen + sichtbar bleibt. +- ↩️ **Bewusst unverändert:** `queue_screen.dart` (Song innerhalb der + bereits offenen Warteschlange antippen) — dort ist man schon im + Player-Kontext, ein erneutes Aufspringen des Vollbild-Players bei jedem + Sprung wäre aufdringlich. +- ✅ **Getestet:** 3 neue Widget-Tests (RED→GREEN, je einer pro Stelle), + volle Suite (590 Tests) grün, `flutter analyze` ohne Befund. + ### 📺🔑 YouTube-Tab: Server-User brauchen keine eigene Baka-Anmeldung mehr (2026-08-26) - 🔑 **Server-User (Dustin, Baka, Tinker) sehen den Baka-Anmelde-Schritt nicht diff --git a/lib/downloads/server_titel_screen.dart b/lib/downloads/server_titel_screen.dart index 84f52eb..bb860e7 100644 --- a/lib/downloads/server_titel_screen.dart +++ b/lib/downloads/server_titel_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../library/database.dart'; import '../library/song_media.dart'; import '../player/audio_handler.dart'; +import '../player/now_playing_screen.dart'; import '../services/navidrome_service.dart'; import '../shared/cover.dart'; import '../shared/lauf_balken.dart'; @@ -77,6 +78,10 @@ class _ServerTitelScreenState extends State { ]; try { final uebersprungen = await handler.loadPlaylist(items, startIndex: ab); + if (!mounted) return; + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NowPlayingScreen()), + ); if (uebersprungen > 0) { messenger.showSnackBar( SnackBar(content: Text(offlineHinweis(uebersprungen))), diff --git a/lib/library/song_list.dart b/lib/library/song_list.dart index 48d30c8..b51045d 100644 --- a/lib/library/song_list.dart +++ b/lib/library/song_list.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../player/audio_handler.dart'; +import '../player/now_playing_screen.dart'; import '../playlists/create_playlist_dialog.dart'; import '../settings/app_settings.dart'; import '../shared/cover.dart'; @@ -378,6 +379,10 @@ class SongZeile extends StatelessWidget { coverOf: (s) => categories.coverFor(s, groupByCategory: settings.groupCoversByCategory), ); + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NowPlayingScreen()), + ); if (uebersprungen > 0) { messenger.showSnackBar( SnackBar(content: Text(offlineHinweis(uebersprungen))), diff --git a/lib/playlists/playlist_detail_screen.dart b/lib/playlists/playlist_detail_screen.dart index ba81b33..6e32ee1 100644 --- a/lib/playlists/playlist_detail_screen.dart +++ b/lib/playlists/playlist_detail_screen.dart @@ -7,6 +7,7 @@ import '../library/database.dart'; import '../library/playlist_service.dart'; import '../library/song_media.dart'; import '../player/audio_handler.dart'; +import '../player/now_playing_screen.dart'; import '../settings/app_settings.dart'; import '../shared/cover.dart'; import '../shared/lauf_balken.dart'; @@ -133,6 +134,10 @@ class _Zeile extends StatelessWidget { coverOf: (s) => categories.coverFor(s, groupByCategory: settings.groupCoversByCategory), ); + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NowPlayingScreen()), + ); if (uebersprungen > 0) { messenger.showSnackBar( SnackBar(content: Text(offlineHinweis(uebersprungen))), diff --git a/test/downloads/server_titel_navigation_test.dart b/test/downloads/server_titel_navigation_test.dart new file mode 100644 index 0000000..915e402 --- /dev/null +++ b/test/downloads/server_titel_navigation_test.dart @@ -0,0 +1,92 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:drift/drift.dart' show 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/downloads/server_titel_screen.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/player/audio_handler.dart'; +import 'package:melo/player/now_playing_screen.dart'; +import 'package:melo/services/navidrome_service.dart'; + +/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff: +/// der spricht über Plattform-Kanäle, die im Widget-Test nicht existieren +/// und mit `MissingPluginException` scheitern würden. Alles andere +/// (Warteschlange, Mediendaten) bleibt echtes Handler-Verhalten. +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 _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()); + handler = _FakeAudioHandler(db); + }); + + tearDown(() { + handler.dispose(); + }); + + Widget wrap(Widget child) => MultiProvider( + providers: [ + Provider.value(value: db), + Provider.value(value: handler), + ], + child: MaterialApp(home: child), + ); + + testWidgets('Titel antippen öffnet den Vollbild-Player', (tester) async { + final navidrome = NavidromeService() + ..setCredentials('https://musik.baka-net.de', 'Baka', 'geheim'); + await tester.pumpWidget(wrap(ServerTitelScreen( + titel: 'Ablaze', + navidrome: navidrome, + holeTitel: () async => const [ + SubsonicSong(id: 's1', titel: 'Feuer', kuenstler: 'X', dauerSekunden: 120), + ], + ))); + await tester.pumpAndSettle(); + + expect(find.byType(NowPlayingScreen), findsNothing); + + await tester.tap(find.text('Feuer')); + await tester.pumpAndSettle(); + + expect(find.byType(NowPlayingScreen), findsOneWidget); + + // Ohne diesen Abschluss bleibt ein drift-Stream-Abo beim Abbau des + // Baums als offener Fake-Timer stehen. + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(milliseconds: 1)); + }); +} diff --git a/test/library/song_list_navigation_test.dart b/test/library/song_list_navigation_test.dart new file mode 100644 index 0000000..920281a --- /dev/null +++ b/test/library/song_list_navigation_test.dart @@ -0,0 +1,128 @@ +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'; + +/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff: +/// der spricht über Plattform-Kanäle, die im Widget-Test nicht existieren +/// und mit `MissingPluginException` scheitern würden. Alles andere +/// (Warteschlange, Mediendaten) bleibt echtes Handler-Verhalten. +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, + ); + + testWidgets('Song antippen öffnet den Vollbild-Player mit großem Cover', + (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, + ), + ), + ), + ), + ); + + expect(find.byType(NowPlayingScreen), findsNothing); + + await tester.tap(find.text('Roses')); + await tester.pumpAndSettle(); + + expect(find.byType(NowPlayingScreen), findsOneWidget); + + // 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_zeile_lauf_test.dart. + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(milliseconds: 1)); + }); +} diff --git a/test/playlists/playlist_detail_navigation_test.dart b/test/playlists/playlist_detail_navigation_test.dart new file mode 100644 index 0000000..9b7b890 --- /dev/null +++ b/test/playlists/playlist_detail_navigation_test.dart @@ -0,0 +1,121 @@ +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/player/audio_handler.dart'; +import 'package:melo/player/now_playing_screen.dart'; +import 'package:melo/playlists/playlist_detail_screen.dart'; +import 'package:melo/settings/app_settings.dart'; + +/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff: +/// der spricht über Plattform-Kanäle, die im Widget-Test nicht existieren +/// und mit `MissingPluginException` scheitern würden. Alles andere +/// (Warteschlange, Mediendaten) bleibt echtes Handler-Verhalten. +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; + late String playlistId; + + 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, + )); + playlistId = await db.createPlaylist('Road Trip'); + await db.addSongToPlaylist(playlistId, 'song-1', 0); + }); + + tearDown(() { + handler.dispose(); + kategorien.dispose(); + playlists.dispose(); + }); + + testWidgets( + 'Song in einer Wiedergabeliste antippen öffnet den Vollbild-Player', + (tester) async { + final playlist = Playlist( + id: playlistId, + name: 'Road Trip', + createdAtMs: 0, + updatedAtMs: 0, + deleted: false, + ); + + 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: PlaylistDetailScreen(playlist: playlist), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(NowPlayingScreen), findsNothing); + + await tester.tap(find.text('Roses')); + await tester.pumpAndSettle(); + + expect(find.byType(NowPlayingScreen), findsOneWidget); + + // Ohne diesen Abschluss bleibt ein drift-Stream-Abo beim Abbau des + // Baums als offener Fake-Timer stehen. + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(milliseconds: 1)); + }); +} -- 2.54.0 From 1b096f52277f3dba293c480921e4762acbacee73 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Thu, 27 Aug 2026 15:18:36 +0200 Subject: [PATCH 04/10] Doppelten Scan-Button auf Android entfernen (Musik scannen und Erneut scannen taten dasselbe) --- CHANGELOG.md | 17 +++++++++++++++++ lib/library/library_service.dart | 7 +++++++ lib/settings/settings_screen.dart | 23 +++++++++++++---------- test/library/library_service_test.dart | 21 +++++++++++++++++++++ 4 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 test/library/library_service_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6c055..82555e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🔄 Doppelten Scan-Button auf Android entfernt (2026-08-27) + +- 🐛 **In den Einstellungen gab es auf Android zwei Buttons für dieselbe + Aktion** ("Musik scannen" und "Erneut scannen") — beide riefen dort + denselben geräteweiten MediaStore-Scan auf (`LibraryService. + pickFolderAndScan()` und `.rescan()` sind auf Android identisch, da + Scoped Storage keine Ordnerwahl erlaubt). Nur auf Desktop unterscheiden + sie sich echt (Ordner hinzufügen vs. bekannte Ordner erneut scannen). +- 🔧 **Fix:** Der zweite `ListTile` ("Erneut scannen") wird in + `settings_screen.dart` nur noch angezeigt, wenn `zeigeErneutScannenButton + (Platform.isAndroid)` das zulässt. Die Sichtbarkeits-Entscheidung ist als + pure Funktion in `library_service.dart` ausgelagert (statt direkt + `Platform.isAndroid` in der UI abzufragen), weil sich die echte Plattform + in Flutter-Tests nicht sauber auf "Android" umschalten lässt. +- ✅ **Getestet:** 2 neue Unit-Tests für `zeigeErneutScannenButton` (RED→ + GREEN), volle Suite (592 Tests) grün, `flutter analyze` ohne Befund. + ### 🎧 Song antippen öffnet den Vollbild-Player (2026-08-27) - 🐛 **Ein Song antippen öffnete früher nur die Wiedergabe, ohne zum diff --git a/lib/library/library_service.dart b/lib/library/library_service.dart index ca142a6..30f1ebd 100644 --- a/lib/library/library_service.dart +++ b/lib/library/library_service.dart @@ -13,6 +13,13 @@ import 'scan_service.dart'; String get addMusicLabel => Platform.isAndroid ? 'Musik scannen' : 'Musikordner hinzufügen'; +/// Ob der "Erneut scannen"-Button zusätzlich zum [addMusicLabel]-Button +/// angezeigt wird. Auf Android tun [LibraryService.pickFolderAndScan] und +/// [LibraryService.rescan] dasselbe (kompletter MediaStore-Scan) — dort +/// reicht ein Button. Pure Funktion statt direkter [Platform.isAndroid]- +/// Abfrage, damit sie unabhängig von der echten Plattform testbar ist. +bool zeigeErneutScannenButton(bool isAndroid) => !isAndroid; + /// Koordiniert Ordnerwahl und Scans; hält den Scan-Fortschritt für die UI. /// Android scannt automatisch über MediaStore (Scoped Storage lässt keinen /// direkten Dateizugriff zu); Desktop scannt vom Nutzer gewählte Ordner. diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index f77b984..7aa9e24 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; @@ -103,16 +105,17 @@ class _SettingsScreenState extends State { enabled: !lib.scanning, onTap: lib.pickFolderAndScan, ), - ListTile( - leading: const Icon(Icons.refresh), - title: const Text('Erneut scannen'), - subtitle: lib.scanning - ? Text('Scanne … ${lib.scanDone}' - '${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}') - : const Text('Bekannte Ordner neu einlesen'), - enabled: !lib.scanning, - onTap: lib.rescan, - ), + if (zeigeErneutScannenButton(Platform.isAndroid)) + ListTile( + leading: const Icon(Icons.refresh), + title: const Text('Erneut scannen'), + subtitle: lib.scanning + ? Text('Scanne … ${lib.scanDone}' + '${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}') + : const Text('Bekannte Ordner neu einlesen'), + enabled: !lib.scanning, + onTap: lib.rescan, + ), ], ), ), diff --git a/test/library/library_service_test.dart b/test/library/library_service_test.dart new file mode 100644 index 0000000..ccf64e8 --- /dev/null +++ b/test/library/library_service_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/library_service.dart'; + +// Bug (Tinker): Auf Android zeigten die Einstellungen zwei Buttons mit +// identischer Aufgabe ("Musik scannen" und "Erneut scannen") — beide riefen +// auf Android denselben Geräte-weiten MediaStore-Scan auf. Die Sichtbarkeit +// des zweiten Buttons hängt eigentlich von Platform.isAndroid ab, das sich +// in Tests nicht sauber auf "Android" umschalten lässt — deshalb ist die +// Entscheidung hier als pure, plattformunabhängig testbare Funktion +// ausgelagert. +void main() { + group('zeigeErneutScannenButton', () { + test('auf Android ausgeblendet (identisch zu "Musik scannen")', () { + expect(zeigeErneutScannenButton(true), isFalse); + }); + + test('auf Desktop angezeigt (echter Unterschied: bekannte Ordner erneut scannen)', () { + expect(zeigeErneutScannenButton(false), isTrue); + }); + }); +} -- 2.54.0 From 90b63c467e6d652267b6cce4368f1ccc4dce6cea Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Thu, 27 Aug 2026 15:31:21 +0200 Subject: [PATCH 05/10] =?UTF-8?q?Zur=C3=BCck-Geste=20auf=20Nicht-Start-Tab?= =?UTF-8?q?s=20navigiert=20zu=20Meine=20Musik=20statt=20die=20App=20zu=20m?= =?UTF-8?q?inimieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab-Wechsel in HomeShell laufen nur über setState (IndexedStack + BottomNavigationBar), nie über Navigator.push — eine System-Zurück-Geste hatte auf Nicht-Start-Tabs also nie eine Route zum Poppen und minimierte stattdessen die App. build() jetzt in ein PopScope gewickelt, das bei _index != 0 zuerst zu Tab 0 zurückspringt. --- CHANGELOG.md | 15 ++++++++ lib/main.dart | 73 ++++++++++++++++++++++----------------- test/home_shell_test.dart | 67 +++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82555e7..17836bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### ⬅️ 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 + dann die System-Zurück-Geste nutzen minimierte die App**, statt zur + vorherigen Ansicht zu navigieren. Grund: Tab-Wechsel in `HomeShell` + laufen nur über `setState` (`IndexedStack` + `BottomNavigationBar`), nie + über `Navigator.push` — es lag also nie eine Route auf dem + Navigator-Stack, die eine Zurück-Geste hätte poppen können. +- 🔧 **Fix:** `build()` in `main.dart` in ein `PopScope` gewickelt + (`canPop: _index == 0`), das bei einem Nicht-Start-Tab zuerst zu Tab 0 + ("Meine Musik") zurückspringt, statt die App zu verlassen. +- ✅ **Getestet:** neuer Widget-Test in `home_shell_test.dart` (RED→GREEN, + simuliert die System-Zurück-Geste über `WidgetsApp.didPopRoute()`), volle + Suite (593 Tests) grün, `flutter analyze` ohne Befund. + ### 🔄 Doppelten Scan-Button auf Android entfernt (2026-08-27) - 🐛 **In den Einstellungen gab es auf Android zwei Buttons für dieselbe diff --git a/lib/main.dart b/lib/main.dart index e9fa0fc..fcaef9e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -222,39 +222,48 @@ class _HomeShellState extends State with WidgetsBindingObserver { const FavoritesScreen(), const YoutubeSearchScreen(), ]; - return Scaffold( - body: Column( - children: [ - Expanded(child: IndexedStack(index: _index, children: tabs)), - const MiniPlayer(), - ], - ), - // Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund - // optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü. - bottomNavigationBar: DecoratedBox( - decoration: const BoxDecoration( - border: Border(top: BorderSide(color: MeloTheme.border)), + return PopScope( + // Tab-Wechsel laufen nur über setState, nie über Navigator.push — die + // Zurück-Geste auf einem Nicht-Start-Tab hätte sonst nichts zum + // Poppen und würde die App minimieren statt zu "Meine Musik" zu gehen. + canPop: _index == 0, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) setState(() => _index = 0); + }, + child: Scaffold( + body: Column( + children: [ + Expanded(child: IndexedStack(index: _index, children: tabs)), + const MiniPlayer(), + ], ), - child: BottomNavigationBar( - currentIndex: _index, - onTap: _goTo, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.headphones), label: 'Meine Musik'), - BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'), - BottomNavigationBarItem( - icon: Icon(Icons.download_outlined), - activeIcon: Icon(Icons.download), - label: 'Download'), - BottomNavigationBarItem( - icon: Icon(Icons.favorite_border), - activeIcon: Icon(Icons.favorite), - label: 'Favoriten'), - BottomNavigationBarItem( - icon: Icon(Icons.smart_display_outlined), - activeIcon: Icon(Icons.smart_display), - label: 'YT-Suche'), - ], + // Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund + // optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü. + bottomNavigationBar: DecoratedBox( + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: MeloTheme.border)), + ), + child: BottomNavigationBar( + currentIndex: _index, + onTap: _goTo, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.headphones), label: 'Meine Musik'), + BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suchen'), + BottomNavigationBarItem( + icon: Icon(Icons.download_outlined), + activeIcon: Icon(Icons.download), + label: 'Download'), + BottomNavigationBarItem( + icon: Icon(Icons.favorite_border), + activeIcon: Icon(Icons.favorite), + label: 'Favoriten'), + BottomNavigationBarItem( + icon: Icon(Icons.smart_display_outlined), + activeIcon: Icon(Icons.smart_display), + label: 'YT-Suche'), + ], + ), ), ), ); diff --git a/test/home_shell_test.dart b/test/home_shell_test.dart index c5b87e7..23f82d8 100644 --- a/test/home_shell_test.dart +++ b/test/home_shell_test.dart @@ -72,4 +72,71 @@ void main() { handler.dispose(); await db.close(); }); + + testWidgets('Zurück-Geste auf einem Nicht-Start-Tab wechselt zu ' + '"Meine Musik" statt die App zu minimieren', (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final lib = LibraryService(db); + final playlists = PlaylistService(db); + final handler = MeloAudioHandler(db: db); + final auth = BakaAuth(speicher: _MemorySpeicher()); + + await tester.pumpWidget(MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: lib), + ChangeNotifierProvider.value(value: playlists), + Provider.value(value: handler), + ChangeNotifierProvider(create: (_) => CategoryService(db)), + ChangeNotifierProvider(create: (_) => AppSettings()), + ChangeNotifierProvider(create: (_) => OfflineMode()), + ChangeNotifierProvider(create: (_) => auth), + ChangeNotifierProvider( + create: (_) => YtDownloadService(auth: auth)), + ChangeNotifierProvider( + create: (_) => YtSearchService(auth: auth)), + ChangeNotifierProvider( + create: (_) => DownloadService(db: db, navidrome: NavidromeService())), + ChangeNotifierProvider( + create: (_) => SyncService( + db: db, cloud: MeloCloudService(auth: auth))), + ], + child: const MaterialApp(home: HomeShell()), + )); + await tester.pump(); + + // Auf "Favoriten" wechseln (Index 3) — kein Navigator.push, nur der Tab. + await tester.tap(find.descendant( + of: find.byType(BottomNavigationBar), matching: find.text('Favoriten'))); + await tester.pump(); + expect( + tester + .widget(find.byType(BottomNavigationBar)) + .currentIndex, + 3); + + // System-Zurück-Geste simulieren — derselbe Weg, über den auch das + // echte Android-Zurück-Gesten-Signal bei WidgetsApp ankommt. + final widgetsAppState = tester.state(find.byType(WidgetsApp)); + // ignore: avoid_dynamic_calls + await (widgetsAppState as dynamic).didPopRoute(); + await tester.pump(); + + // Ohne Abfangen bliebe der Tab unverändert (Flutter minimiert dann die + // App statt zu navigieren) — die Geste muss stattdessen zu Tab 0 führen. + expect( + tester + .widget(find.byType(BottomNavigationBar)) + .currentIndex, + 0); + + // Baum abbauen, bevor der Test endet: der Positions-Timer des Audio- + // Handlers läuft sonst über das Testende hinaus weiter, und der + // Test-Rahmen meldet einen offenen Timer (wie in hauptmenue_test.dart + // dokumentiert). Aus demselben Grund bewusst kein db.close() nach + // einem tester.tap. + await tester.pumpWidget(const SizedBox()); + handler.dispose(); + await tester.pump(const Duration(milliseconds: 1)); + }); } -- 2.54.0 From 2cabe36a6cebcbac7f4c702f2b0e3d338903a42d Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Sat, 29 Aug 2026 11:24:42 +0200 Subject: [PATCH 06/10] =?UTF-8?q?Zuletzt=20zeigt=20zuletzt=20Geh=C3=B6rtes?= =?UTF-8?q?=20statt=20neu=20Hinzugef=C3=BCgtes;=20Herz=20sitzt=20jetzt=20n?= =?UTF-8?q?eben=20dem=20Songnamen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MeloDb.watchRecentlyPlayed(): wertet PlaybackHistory statt dateAddedMs aus, ein mehrfach gehörter Song erscheint nur einmal, an der Position seines jüngsten Abspielens (Subquery mit GROUP BY songId / MAX(playedAtMs)). - "Zuletzt" (Karte + RecentlyPlayedScreen, vormals RecentlyAddedScreen) in my_music_screen.dart nutzt jetzt watchRecentlyPlayed() statt watchRecent(). - Herz im Vollbild-Player: aus der AppBar-actions-Row entfernt, sitzt jetzt direkt neben dem Songtitel in _Angaben (now_playing_screen.dart). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP --- CHANGELOG.md | 54 +++++++++ lib/library/database.dart | 29 +++++ lib/library/my_music_screen.dart | 18 +-- lib/player/now_playing_screen.dart | 48 ++++---- test/library/recently_played_test.dart | 73 +++++++++++ test/player/now_playing_heart_test.dart | 155 ++++++++++++++++++++++++ 6 files changed, 345 insertions(+), 32 deletions(-) create mode 100644 test/library/recently_played_test.dart create mode 100644 test/player/now_playing_heart_test.dart 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); + }); +} -- 2.54.0 From 609ca37edebc96e3c48ece20e5379b662bc68103 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Sat, 29 Aug 2026 11:31:28 +0200 Subject: [PATCH 07/10] Auto-Anmeldung: doppelten Login-Request beim App-Start verhindert (Race durch IndexedStack) Betraf alle drei Nutzer (Dustin, Baka, Tinker), nicht nur einen: IndexedStack baut Download-Tab und YT-Suche beim App-Start gleichzeitig, beide loesen unabhaengig voneinander autoAnmelden() aus. Ohne In-Flight-Absicherung gingen zwei fast-gleichzeitige Login-Requests an denselben Server, von denen einer abgelehnt wurde - obwohl die Zugangsdaten korrekt waren. - BakaAuth.autoAnmelden() haengt einen ueberlappenden Aufruf jetzt an den bereits laufenden Login-Versuch an, statt einen zweiten Request zu senden. - Anmelde-Dialog im Download-Tab zeigt den tatsaechlich bekannten Navidrome-Benutzernamen statt immer "Baka" vorauszufuellen. - Navidrome-Dialog in den Einstellungen waehlt den zuletzt bekannten Benutzer vor statt immer "Baka". --- lib/downloads/downloads_screen.dart | 11 ++++++++--- lib/services/baka_auth.dart | 10 +++++++++- lib/settings/settings_screen.dart | 7 ++++++- test/downloads/online_screen_test.dart | 20 ++++++++++++++++++++ test/services/baka_auth_test.dart | 24 ++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index fd2c99a..5326a71 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -679,9 +679,10 @@ class _YouTubeBereichState extends State<_YouTubeBereich> { } Future _anmelden() async { + final nav = context.read().navidrome; await showDialog( context: context, - builder: (_) => const _AnmeldeDialog(), + builder: (_) => _AnmeldeDialog(benutzer: nav.benutzer), ); } @@ -869,14 +870,18 @@ class _YouTubeBereichState extends State<_YouTubeBereich> { /// Fragt Benutzername und Passwort ab und gibt beides zurück. class _AnmeldeDialog extends StatefulWidget { - const _AnmeldeDialog(); + const _AnmeldeDialog({this.benutzer = ''}); + + /// Bereits bekannter Navidrome-Benutzername (leer, falls keiner bekannt) — + /// vorausgefüllt statt immer "Baka" zu zeigen, egal wer die App nutzt. + final String benutzer; @override State<_AnmeldeDialog> createState() => _AnmeldeDialogState(); } class _AnmeldeDialogState extends State<_AnmeldeDialog> { - final _benutzer = TextEditingController(text: 'Baka'); + late final _benutzer = TextEditingController(text: widget.benutzer); final _passwort = TextEditingController(); bool _sichtbar = false; bool _verbindet = false; diff --git a/lib/services/baka_auth.dart b/lib/services/baka_auth.dart index ecfa178..29c0cba 100644 --- a/lib/services/baka_auth.dart +++ b/lib/services/baka_auth.dart @@ -48,6 +48,12 @@ class BakaAuth extends ChangeNotifier { String? _token; String _benutzer = ''; + /// Laufender Login-Versuch aus [autoAnmelden] — solange er offen ist, + /// hängt sich ein überlappender Aufruf daran statt einen zweiten + /// Request loszuschicken (Race Condition: mehrere Tabs im IndexedStack + /// lösen beim App-Start gleichzeitig autoAnmelden aus). + Future? _laufenderAutoLogin; + bool get istAngemeldet => _token != null && _token!.isNotEmpty; String get benutzer => _benutzer; @@ -108,7 +114,9 @@ class BakaAuth extends ChangeNotifier { /// authentifizierten Aufruf ohnehin automatisch. Future autoAnmelden(String benutzer, String passwort) async { if (istAngemeldet || benutzer.isEmpty || passwort.isEmpty) return; - await anmelden(benutzer, passwort); + _laufenderAutoLogin ??= anmelden(benutzer, passwort) + .whenComplete(() => _laufenderAutoLogin = null); + await _laufenderAutoLogin; } Future abmelden() async { diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 7aa9e24..5131c5f 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -346,7 +346,12 @@ class _SettingsScreenState extends State { void _zeigeNavidromeDialog() { final urlCtrl = TextEditingController(text: 'https://musik.baka-net.de'); final passCtrl = TextEditingController(); - String selectedUser = 'Baka'; + // Vorauswahl: der zuletzt bekannte Navidrome-Benutzer, falls er einer + // der drei Server-User ist — sonst Fallback "Baka". + const bekannteBenutzer = ['Baka', 'Tinker', 'Dustin']; + String selectedUser = bekannteBenutzer.contains(_navidrome.benutzer) + ? _navidrome.benutzer + : 'Baka'; bool verbindet = false; bool obscure = true; String? fehler; diff --git a/test/downloads/online_screen_test.dart b/test/downloads/online_screen_test.dart index 1d21203..d439c99 100644 --- a/test/downloads/online_screen_test.dart +++ b/test/downloads/online_screen_test.dart @@ -248,6 +248,26 @@ void main() { findsOneWidget); }); + testWidgets( + 'Der Anmelde-Dialog schlägt den bekannten Navidrome-Benutzer vor, ' + 'nicht immer "Baka"', (tester) async { + final auth = BakaAuth( + client: MockClient((_) async => http.Response('', 500)), + speicher: _MemorySpeicher()); + final nav = NavidromeService() + ..setCredentials('https://musik.baka-net.de', 'Tinker', 'falsch'); + + await tester.pumpWidget(_wrap(auth, + db: db, categories: categories, navidrome: nav)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Beim Baka-Konto anmelden')); + await tester.pumpAndSettle(); + + final feld = tester.widget(find.byType(TextField).first); + expect(feld.controller!.text, 'Tinker'); + }); + testWidgets( 'Server-User meldet sich im Hintergrund mit denselben Zugangsdaten ' 'beim Baka-Konto an', (tester) async { diff --git a/test/services/baka_auth_test.dart b/test/services/baka_auth_test.dart index 6be2650..97e5357 100644 --- a/test/services/baka_auth_test.dart +++ b/test/services/baka_auth_test.dart @@ -115,6 +115,30 @@ void main() { expect(auth.benutzer, 'Baka'); }); + test( + 'autoAnmelden löst bei gleichzeitigen Aufrufen nur EINEN HTTP-Request aus ' + '(Race Condition durch IndexedStack, das mehrere Tabs gleichzeitig baut)', + () async { + var aufrufe = 0; + final client = MockClient((_) async { + aufrufe++; + // Künstliche Verzögerung: simuliert, dass der erste Request noch + // nicht zurück ist, wenn der zweite Aufruf startet. + await Future.delayed(const Duration(milliseconds: 50)); + return http.Response(jsonEncode({'token': 'jwt-tinker'}), 200); + }); + final auth = BakaAuth(client: client, speicher: _MemorySpeicher()); + + await Future.wait([ + auth.autoAnmelden('Tinker', 'geheim'), + auth.autoAnmelden('Tinker', 'geheim'), + ]); + + expect(aufrufe, 1); + expect(auth.istAngemeldet, isTrue); + expect(auth.benutzer, 'Tinker'); + }); + test('autoAnmelden tut nichts ohne Zugangsdaten', () async { var aufrufe = 0; final client = MockClient((_) async { -- 2.54.0 From 196303145a33b88b2acc3ca4233538d0107f853d Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Sat, 29 Aug 2026 12:38:20 +0200 Subject: [PATCH 08/10] =?UTF-8?q?Manuelle=20Baka-Anmeldung=20=C3=BCbernimm?= =?UTF-8?q?t=20das=20Passwort=20jetzt=20dauerhaft;=20Auto-Login-Fehler=20w?= =?UTF-8?q?erden=20geloggt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nach erfolgreichem manuellem Login im "Baka-Konto"-Dialog wird das neue Passwort jetzt zusätzlich in NavidromeService persistiert (dafür ergänzt: öffentlicher serverUrl-Getter) — vorher schrieb der Dialog es nur ins BakaAuth-Token, nie zurück in die lokale Navidrome-Ablage, aus der autoAnmelden() sich beim nächsten App-Start bedient. Ohne diesen Fix kam der Auto-Login-Fehler nach Ablauf des 7-Tage-Tokens garantiert wieder. Zusätzlich hält BakaAuth.letzterAutoLoginFehler jetzt den Fehlertext des letzten Auto-Login-Versuchs fest, statt ihn zu verwerfen — bisher war aus der App nicht diagnostizierbar, woran ein Auto-Login scheiterte. --- CHANGELOG.md | 32 +++++++++++++++++++++++++ lib/downloads/downloads_screen.dart | 8 +++++++ lib/services/baka_auth.dart | 14 ++++++++++- lib/services/navidrome_service.dart | 1 + test/downloads/online_screen_test.dart | 33 ++++++++++++++++++++++++++ test/services/baka_auth_test.dart | 14 +++++++++++ 6 files changed, 101 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5c4f5f..8c837db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🔑 Manuelle Baka-Anmeldung übernahm das neue Passwort nie dauerhaft; Auto-Login-Fehler wurde verworfen (2026-08-29) + +- 🐛 **Wer sich nach einer fehlgeschlagenen Auto-Anmeldung manuell im + "Baka-Konto"-Dialog mit dem korrekten (neuen) Passwort anmeldete, bekam + denselben Fehler beim nächsten App-Start (nach Ablauf des 7-Tage-Tokens) + garantiert wieder** — unabhängig vom Server-Zustand. Grund: Navidrome- + und Baka-Konto teilen sich laut Design dieselben Zugangsdaten + (`navidrome_service.dart`), aber der manuelle Anmelde-Dialog + (`_AnmeldeDialogState._anmelden()` in `downloads_screen.dart`) schrieb + das eingegebene Passwort nur ins `BakaAuth`-Token — nie zurück in die + lokale Navidrome-Zugangsdaten-Ablage (`NavidromeService`), aus der + `BakaAuth.autoAnmelden()` sich beim nächsten Start bedient. Der manuelle + Login "half" also nur bis zum nächsten Token-Ablauf. +- 🔧 **Fix:** Nach erfolgreichem manuellem Login persistiert der Dialog die + neuen Zugangsdaten jetzt zusätzlich über + `NavidromeService.speichereZugangsdaten(...)`. Dafür bekam + `NavidromeService` einen fehlenden öffentlichen Getter `serverUrl`. +- 🐛 **`BakaAuth.autoAnmelden()` verwarf den Fehlertext von `anmelden()` + komplett** — aus der App war nicht diagnostizierbar, ob ein Auto-Login + an falschem Passwort, Rate-Limit oder Verbindungsfehler scheiterte. +- 🔧 **Fix:** neues Feld `BakaAuth.letzterAutoLoginFehler` hält den + Fehlertext des letzten Auto-Login-Versuchs fest (zusätzlich per + `debugPrint` geloggt) — die bestehende Race-Condition-Absicherung über + `_laufenderAutoLogin` bleibt unverändert erhalten. +- ✅ **Getestet (RED→GREEN):** neuer Widget-Test in `online_screen_test.dart` + bestätigt, dass `nav.passwort`/`nav.benutzer` nach erfolgreichem + manuellem Login den neu eingegebenen Wert zeigen; neuer Unit-Test in + `baka_auth_test.dart` bestätigt, dass `letzterAutoLoginFehler` nach + einem fehlgeschlagenen `autoAnmelden()` den Fehlertext trägt statt + `null` zu bleiben. Volle Suite (601 Tests) grün, `flutter analyze` ohne + Befund. + ### 🎧 "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, diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index 5326a71..8fbdce1 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -903,6 +903,14 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> { final fehler = await auth.anmelden(_benutzer.text.trim(), _passwort.text); if (!mounted) return; if (fehler == null) { + // Navidrome- und Baka-Konto teilen sich die Zugangsdaten (siehe + // navidrome_service.dart) — ohne diese Übernahme würde der nächste + // Auto-Login nach Ablauf des Baka-Tokens wieder mit dem alten + // Passwort scheitern. + final nav = context.read().navidrome; + await nav.speichereZugangsdaten( + nav.serverUrl, _benutzer.text.trim(), _passwort.text); + if (!mounted) return; Navigator.pop(context); return; } diff --git a/lib/services/baka_auth.dart b/lib/services/baka_auth.dart index 29c0cba..ca10d08 100644 --- a/lib/services/baka_auth.dart +++ b/lib/services/baka_auth.dart @@ -54,6 +54,10 @@ class BakaAuth extends ChangeNotifier { /// lösen beim App-Start gleichzeitig autoAnmelden aus). Future? _laufenderAutoLogin; + /// Fehlertext des letzten automatischen Anmeldeversuchs — für Diagnose, + /// da autoAnmelden() (anders als anmelden()) keinen Rückgabewert hat. + String? letzterAutoLoginFehler; + bool get istAngemeldet => _token != null && _token!.isNotEmpty; String get benutzer => _benutzer; @@ -114,11 +118,19 @@ class BakaAuth extends ChangeNotifier { /// authentifizierten Aufruf ohnehin automatisch. Future autoAnmelden(String benutzer, String passwort) async { if (istAngemeldet || benutzer.isEmpty || passwort.isEmpty) return; - _laufenderAutoLogin ??= anmelden(benutzer, passwort) + _laufenderAutoLogin ??= _autoAnmeldenIntern(benutzer, passwort) .whenComplete(() => _laufenderAutoLogin = null); await _laufenderAutoLogin; } + Future _autoAnmeldenIntern(String benutzer, String passwort) async { + final fehler = await anmelden(benutzer, passwort); + letzterAutoLoginFehler = fehler; + if (fehler != null) { + debugPrint('Auto-Anmeldung fehlgeschlagen: $fehler'); + } + } + Future abmelden() async { _token = null; _benutzer = ''; diff --git a/lib/services/navidrome_service.dart b/lib/services/navidrome_service.dart index a55bafe..92995af 100644 --- a/lib/services/navidrome_service.dart +++ b/lib/services/navidrome_service.dart @@ -145,6 +145,7 @@ class NavidromeService { /// Zugangsdaten. String get benutzer => _user; String get passwort => _password; + String get serverUrl => _serverUrl; /// Dekodiert eine Subsonic-JSON-Antwort und wirft [NavidromeException], /// wenn der Server `status:"failed"` meldet (z. B. falsches Passwort) — diff --git a/test/downloads/online_screen_test.dart b/test/downloads/online_screen_test.dart index d439c99..607104b 100644 --- a/test/downloads/online_screen_test.dart +++ b/test/downloads/online_screen_test.dart @@ -268,6 +268,39 @@ void main() { expect(feld.controller!.text, 'Tinker'); }); + testWidgets( + 'Erfolgreiches manuelles Anmelden übernimmt das neue Passwort auch ' + 'für Navidrome — sonst kommt der Auto-Login-Fehler nach Ablauf des ' + 'Baka-Tokens garantiert wieder', (tester) async { + final auth = BakaAuth( + client: MockClient((request) async { + final body = jsonDecode(request.body) as Map; + if (body['password'] == 'neuesPasswort') { + return http.Response(jsonEncode({'token': 'jwt-neu'}), 200); + } + return http.Response('', 500); + }), + speicher: _MemorySpeicher(), + ); + final nav = NavidromeService() + ..setCredentials('https://musik.baka-net.de', 'Tinker', 'altesPasswort'); + + await tester.pumpWidget(_wrap(auth, + db: db, categories: categories, navidrome: nav)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Beim Baka-Konto anmelden')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).last, 'neuesPasswort'); + await tester.tap(find.text('Anmelden')); + await tester.pumpAndSettle(); + + expect(nav.passwort, 'neuesPasswort'); + expect(nav.benutzer, 'Tinker'); + expect(auth.istAngemeldet, isTrue); + }); + testWidgets( 'Server-User meldet sich im Hintergrund mit denselben Zugangsdaten ' 'beim Baka-Konto an', (tester) async { diff --git a/test/services/baka_auth_test.dart b/test/services/baka_auth_test.dart index 97e5357..2e0b74f 100644 --- a/test/services/baka_auth_test.dart +++ b/test/services/baka_auth_test.dart @@ -139,6 +139,20 @@ void main() { expect(auth.benutzer, 'Tinker'); }); + test( + 'autoAnmelden merkt sich den Fehlergrund für die Diagnose, da es ' + 'selbst keinen Rückgabewert liefert', () async { + final client = MockClient((_) async => http.Response( + jsonEncode({'status': 'error', 'message': 'Falsches Passwort'}), + 200)); + final auth = BakaAuth(client: client, speicher: _MemorySpeicher()); + + await auth.autoAnmelden('Tinker', 'falsch'); + + expect(auth.letzterAutoLoginFehler, 'Falsches Passwort'); + expect(auth.istAngemeldet, isFalse); + }); + test('autoAnmelden tut nichts ohne Zugangsdaten', () async { var aufrufe = 0; final client = MockClient((_) async { -- 2.54.0 From e2304b66cd8e6d544d5ceaee9182534f740e0946 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Sat, 29 Aug 2026 12:51:04 +0200 Subject: [PATCH 09/10] =?UTF-8?q?Gestaffelte=20Enter-Animation=20f=C3=BCr?= =?UTF-8?q?=20Songlisten=20(EinblendItem)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Items in song_list.dart und artist_list.dart faden/rutschen beim ersten Erscheinen gestaffelt statt abrupt ein — angelehnt an HyperOS' fließende Motion-Sprache. Läuft einmal pro Element, respektiert Reduce-Motion und deckelt die Verzögerung ab Index 12. --- CHANGELOG.md | 28 +++++++++ lib/library/artist_list.dart | 46 +++++++++------ lib/library/song_list.dart | 20 ++++--- lib/shared/einblend_item.dart | 91 +++++++++++++++++++++++++++++ test/shared/einblend_item_test.dart | 76 ++++++++++++++++++++++++ 5 files changed, 235 insertions(+), 26 deletions(-) create mode 100644 lib/shared/einblend_item.dart create mode 100644 test/shared/einblend_item_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c837db..af12d9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### ✨ Staggered List-Enter-Animation: Listen faden beim ersten Erscheinen gestaffelt ein (2026-08-29) + +- ✅ **Teil 1 einer Animations-Serie** (inspiriert von Xiaomis HyperOS- + Design-Sprache — fließende statt abrupte Bewegung): neues Widget + `EinblendItem` (`lib/shared/einblend_item.dart`) blendet ein Listenelement + beim ersten Erscheinen mit Opacity 0→1 und leichtem Versatz (`MeloSpace.sm` + → 0) ein, Dauer `MeloMotion.normal` (220ms), Kurve `MeloMotion.curve`. + Jedes Element startet um `min(index, 12) × 25ms` versetzt — ab Index 12 + ohne zusätzliche Verzögerung, damit lange Listen nicht träge wirken. Läuft + genau einmal je Element-Lebenszeit (nicht bei jedem Rebuild) und + respektiert Reduce-Motion (`MeloMotion.ruhig`): dann erscheint das Element + sofort im Endzustand, ohne Verzögerung oder Bewegung. +- 🔧 Eingebunden in `SongList` (`lib/library/song_list.dart`) und in beide + Listen von `artist_list.dart` (Künstler-Übersicht sowie die Zeilen der + Künstler-Detailseite — Alben, Titel und Zwischenüberschriften). Die + wiederverwendete `SongZeile` selbst bleibt unverändert, damit Suche und + Playlist-Detail (die den Wrapper nicht bekommen sollen) unangetastet + bleiben. `my_music_screen.dart` rendert keine eigene Liste (nur + eingebettete `SortableSongList`/`ArtistListScreen`/`CategoryListScreen`) + — dort war nichts einzubinden. +- ✅ **Getestet (RED→GREEN):** neuer Widget-Test + `test/shared/einblend_item_test.dart` prüft Start bei Opacity 0, Ziel bei + 1,0 nach `pumpAndSettle()`, sofortigen Endzustand bei + `disableAnimations: true` sowie den Deckel bei Index 12 (Index 50 startet + nicht später als Index 12). Bestehende Tests zu `song_list.dart` liefen + bereits mit `pumpAndSettle()` und blieben unverändert grün. Volle Suite + (604 Tests) grün, `flutter analyze` ohne Befund. + ### 🔑 Manuelle Baka-Anmeldung übernahm das neue Passwort nie dauerhaft; Auto-Login-Fehler wurde verworfen (2026-08-29) - 🐛 **Wer sich nach einer fehlgeschlagenen Auto-Anmeldung manuell im diff --git a/lib/library/artist_list.dart b/lib/library/artist_list.dart index a69ba7b..9b9840c 100644 --- a/lib/library/artist_list.dart +++ b/lib/library/artist_list.dart @@ -5,6 +5,7 @@ import '../settings/app_settings.dart'; import '../shared/auswahl.dart'; import '../shared/auswahl_leiste.dart'; import '../shared/cover.dart'; +import '../shared/einblend_item.dart'; import '../shared/theme.dart'; import '../shared/titel_listen_screen.dart'; import 'category_service.dart'; @@ -44,7 +45,10 @@ class _ArtistListScreenState extends State { itemCount: artists.length, itemBuilder: (context, i) { final artist = artists[i]; - return _KuenstlerZeile(name: artist, songs: grouped[artist]!); + return EinblendItem( + index: i, + child: _KuenstlerZeile(name: artist, songs: grouped[artist]!), + ); }, ); }, @@ -167,25 +171,31 @@ class _KuenstlerScreenState extends State { body: ListView.builder( itemCount: vorspann + widget.songs.length, itemBuilder: (context, i) { - if (zeigeAlben) { - if (i == 0) return const _Kopf('Alben'); - if (i <= albumListe.length) { - final eintrag = albumListe[i - 1]; - return _AlbumZeile(name: eintrag.key, songs: eintrag.value); + Widget zeile() { + if (zeigeAlben) { + if (i == 0) return const _Kopf('Alben'); + if (i <= albumListe.length) { + final eintrag = albumListe[i - 1]; + return _AlbumZeile(name: eintrag.key, songs: eintrag.value); + } + if (i == albumListe.length + 1) { + return const Divider(height: 1); + } + if (i == albumListe.length + 2) return const _Kopf('Alle Titel'); } - if (i == albumListe.length + 1) return const Divider(height: 1); - if (i == albumListe.length + 2) return const _Kopf('Alle Titel'); + final index = i - vorspann; + final song = widget.songs[index]; + return SongZeile( + song: song, + warteschlange: widget.songs, + index: index, + auswahlModus: _waehltAus, + ausgewaehlt: _auswahl.contains(song.id), + onAuswahlWechsel: () => _wechsleAuswahl(song.id), + ); } - final index = i - vorspann; - final song = widget.songs[index]; - return SongZeile( - song: song, - warteschlange: widget.songs, - index: index, - auswahlModus: _waehltAus, - ausgewaehlt: _auswahl.contains(song.id), - onAuswahlWechsel: () => _wechsleAuswahl(song.id), - ); + + return EinblendItem(index: i, child: zeile()); }, ), ), diff --git a/lib/library/song_list.dart b/lib/library/song_list.dart index b51045d..1e714af 100644 --- a/lib/library/song_list.dart +++ b/lib/library/song_list.dart @@ -7,6 +7,7 @@ import '../player/now_playing_screen.dart'; import '../playlists/create_playlist_dialog.dart'; import '../settings/app_settings.dart'; import '../shared/cover.dart'; +import '../shared/einblend_item.dart'; import '../shared/favorite_button.dart'; import '../shared/lauf_balken.dart'; import 'category_service.dart'; @@ -94,15 +95,18 @@ class SongList extends StatelessWidget { controller: controller, itemExtent: festeHoehe ? songZeilenHoeheFuer(context) : null, itemCount: songs.length, - itemBuilder: (context, i) => SongZeile( - song: songs[i], - warteschlange: songs, + itemBuilder: (context, i) => EinblendItem( index: i, - auswahlModus: gewaehlt != null && gewaehlt.isNotEmpty, - ausgewaehlt: gewaehlt?.contains(songs[i].id) ?? false, - onAuswahlWechsel: onAuswahlWechsel == null - ? null - : () => onAuswahlWechsel!(songs[i].id), + child: SongZeile( + song: songs[i], + warteschlange: songs, + index: i, + auswahlModus: gewaehlt != null && gewaehlt.isNotEmpty, + ausgewaehlt: gewaehlt?.contains(songs[i].id) ?? false, + onAuswahlWechsel: onAuswahlWechsel == null + ? null + : () => onAuswahlWechsel!(songs[i].id), + ), ), ); } diff --git a/lib/shared/einblend_item.dart b/lib/shared/einblend_item.dart new file mode 100644 index 0000000..7de33fa --- /dev/null +++ b/lib/shared/einblend_item.dart @@ -0,0 +1,91 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'theme.dart'; + +/// Blendet [child] beim ersten Erscheinen leicht gestaffelt ein — statt +/// abrupt aufzupoppen, faded und rutscht es ein kleines Stück von unten +/// heran. Je weiter hinten in der Liste, desto später beginnt es, damit die +/// Liste als Ganzes fließt statt gleichzeitig aufzublitzen. +/// +/// Läuft genau einmal je Element-Lebenszeit: ein Rebuild (etwa weil sich der +/// Song-Titel ändert) startet die Animation nicht erneut. +class EinblendItem extends StatefulWidget { + const EinblendItem({super.key, required this.child, required this.index}); + + final Widget child; + + /// Position in der Liste — bestimmt die Verzögerung vor dem Start. + final int index; + + @override + State createState() => _EinblendItemState(); +} + +class _EinblendItemState extends State + with SingleTickerProviderStateMixin { + /// Ab Index 12 keine zusätzliche Verzögerung mehr — sonst wirkte eine + /// lange Liste beim ersten Erscheinen träge, weil die letzten Zeilen erst + /// spät anfingen. + static const _deckel = 12; + static const _versatzProItem = Duration(milliseconds: 25); + + late final AnimationController _controller = AnimationController( + vsync: this, + duration: MeloMotion.normal, + ); + late final Animation _kurve = CurvedAnimation( + parent: _controller, + curve: MeloMotion.curve, + ); + + bool _gestartet = false; + + // Kein Start in initState: die Entscheidung hängt an MediaQuery + // (Reduce-Motion), und darauf darf man dort noch nicht zugreifen — siehe + // LaufBalken. didChangeDependencies läuft unmittelbar danach und ein + // _gestartet-Merker verhindert, dass ein späterer Aufruf (z. B. durch + // eine geänderte Textgröße) die Animation erneut anstößt. + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_gestartet) return; + _gestartet = true; + + if (MeloMotion.ruhig(context, MeloMotion.normal) == Duration.zero) { + _controller.value = 1; + return; + } + + final verzoegerung = _versatzProItem * math.min(widget.index, _deckel); + if (verzoegerung == Duration.zero) { + _controller.forward(); + } else { + Future.delayed(verzoegerung, () { + if (mounted) _controller.forward(); + }); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _kurve, + builder: (context, child) => Opacity( + opacity: _kurve.value, + child: Transform.translate( + offset: Offset(0, MeloSpace.sm * (1 - _kurve.value)), + child: child, + ), + ), + child: widget.child, + ); + } +} diff --git a/test/shared/einblend_item_test.dart b/test/shared/einblend_item_test.dart new file mode 100644 index 0000000..9353db0 --- /dev/null +++ b/test/shared/einblend_item_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/shared/einblend_item.dart'; + +void main() { + Widget wrap(Widget child, {bool ruhig = false}) => MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: ruhig), + child: Scaffold(body: child), + ), + ); + + double opacityVon(WidgetTester tester, Finder einblendItem) => tester + .widget( + find.descendant(of: einblendItem, matching: find.byType(Opacity))) + .opacity; + + double versatzYVon(WidgetTester tester, Finder einblendItem) => tester + .widget( + find.descendant(of: einblendItem, matching: find.byType(Transform))) + .transform + .getTranslation() + .y; + + testWidgets('Opacity startet bei 0 und erreicht 1,0 nach dem Einblenden', + (tester) async { + await tester.pumpWidget(wrap( + const EinblendItem(index: 0, child: Text('Titel')), + )); + final item = find.byType(EinblendItem); + + expect(opacityVon(tester, item), 0); + + await tester.pumpAndSettle(); + + expect(opacityVon(tester, item), 1); + expect(versatzYVon(tester, item), 0); + }); + + testWidgets( + 'bei abgeschalteten Systemanimationen erscheint das Element sofort', + (tester) async { + await tester.pumpWidget(wrap( + const EinblendItem(index: 3, child: Text('Titel')), + ruhig: true, + )); + final item = find.byType(EinblendItem); + + // Bewusst kein weiterer pump() davor: die Reduce-Motion-Einstellung + // darf nicht erst nach einer Zeitverzögerung greifen. + expect(opacityVon(tester, item), 1); + expect(versatzYVon(tester, item), 0); + }); + + testWidgets('der Deckel bremst die Verzögerung ab Index 12', + (tester) async { + await tester.pumpWidget(wrap( + const Column( + children: [ + EinblendItem(index: 12, child: Text('Zwölf')), + EinblendItem(index: 50, child: Text('Fünfzig')), + ], + ), + )); + + // Deckel: min(index, 12) * 25ms → beide starten spätestens nach 300ms. + // Ohne Deckel bräuchte Index 50 1250ms Verzögerung und wäre hier noch + // bei Opacity 0. + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 220)); + + for (final item in find.byType(EinblendItem).evaluate()) { + expect(opacityVon(tester, find.byWidget(item.widget)), 1); + } + }); +} -- 2.54.0 From 824f6364e2a22419cad32b947d1faa9751043b34 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Sat, 29 Aug 2026 18:08:19 +0200 Subject: [PATCH 10/10] Baka-Konto-Dialog: Passwort wird jetzt auch getrimmt (wie im Navidrome-Dialog) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Passwortfeld im Baka-Konto-Anmelde-Dialog wurde vor dem Absenden und vor dem Speichern nicht getrimmt — ein durch Autofill eingefügtes Leerzeichen ließ korrekte Logins scheitern. Gleicher Fix wie bereits in settings_screen.dart (Commit 3d1e00646ac) für denselben Symptom-Typ. --- CHANGELOG.md | 23 +++++++++++++++++ lib/downloads/downloads_screen.dart | 7 ++++-- test/downloads/online_screen_test.dart | 34 ++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af12d9a..132f464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🔑 Baka-Konto-Dialog trimmte das Passwort nicht — Autofill-Leerzeichen ließ korrekte Logins scheitern (2026-08-29) + +- 🐛 **Im "Baka-Konto"-Anmelde-Dialog (`_AnmeldeDialogState._anmelden()` in + `downloads_screen.dart`) wurde der Benutzername vor dem Absenden getrimmt + (`_benutzer.text.trim()`), das Passwort aber nicht** (`_passwort.text` + roh, an beiden Stellen: beim `BakaAuth.anmelden(...)`-Aufruf UND beim + anschließenden `NavidromeService.speichereZugangsdaten(...)`). Ein durch + Autofill/Tastatur-Vorschläge im `obscureText`-Passwortfeld eingefügtes + unsichtbares Leerzeichen landete dadurch im gesendeten UND dauerhaft + gespeicherten Passwort — der Login schlug fehl, obwohl der Nutzer sein + Passwort korrekt kannte. +- 🔧 **Fix:** `_passwort.text.trim()` an beiden Stellen, mit demselben + Kommentar wie beim strukturell identischen, bereits behobenen Fall im + Navidrome-Login-Dialog (`settings_screen.dart`, Commit `3d1e00646ac`) — + das war schon einmal exakt dasselbe Symptom in einem zweiten + Passwort-Dialog. Falls das Muster ein drittes Mal auftaucht: derselbe + Trim-Fix, derselbe Kommentar. +- ✅ **Getestet (RED→GREEN):** neuer Widget-Test in `online_screen_test.dart` + gibt im Passwortfeld `'geheim '` (mit Leerzeichen) ein und bestätigt, dass + sowohl der gesendete `POST /auth/login`-Body als auch + `nav.passwort` das getrimmte `'geheim'` enthalten. Volle Suite (605 Tests) + grün, `flutter analyze` ohne Befund. + ### ✨ Staggered List-Enter-Animation: Listen faden beim ersten Erscheinen gestaffelt ein (2026-08-29) - ✅ **Teil 1 einer Animations-Serie** (inspiriert von Xiaomis HyperOS- diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index 8fbdce1..d2ee725 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -900,7 +900,10 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> { _fehler = null; }); final auth = context.read(); - final fehler = await auth.anmelden(_benutzer.text.trim(), _passwort.text); + // Randständige Leerzeichen entfernen (verdeckte + // Fehlerquelle im obscureText-Feld / bei Autofill). + final passwort = _passwort.text.trim(); + final fehler = await auth.anmelden(_benutzer.text.trim(), passwort); if (!mounted) return; if (fehler == null) { // Navidrome- und Baka-Konto teilen sich die Zugangsdaten (siehe @@ -909,7 +912,7 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> { // Passwort scheitern. final nav = context.read().navidrome; await nav.speichereZugangsdaten( - nav.serverUrl, _benutzer.text.trim(), _passwort.text); + nav.serverUrl, _benutzer.text.trim(), passwort); if (!mounted) return; Navigator.pop(context); return; diff --git a/test/downloads/online_screen_test.dart b/test/downloads/online_screen_test.dart index 607104b..f0facf7 100644 --- a/test/downloads/online_screen_test.dart +++ b/test/downloads/online_screen_test.dart @@ -301,6 +301,40 @@ void main() { expect(auth.istAngemeldet, isTrue); }); + testWidgets( + 'Anmelden trimmt das Passwort — ein unsichtbares Leerzeichen durch ' + 'Autofill im obscureText-Feld darf den Login nicht scheitern lassen ' + '(wie im Navidrome-Dialog, settings_screen.dart)', (tester) async { + late String gesendetesPasswort; + final auth = BakaAuth( + client: MockClient((request) async { + final body = jsonDecode(request.body) as Map; + gesendetesPasswort = body['password'] as String; + if (body['password'] == 'geheim') { + return http.Response(jsonEncode({'token': 'jwt-neu'}), 200); + } + return http.Response('', 500); + }), + speicher: _MemorySpeicher(), + ); + final nav = NavidromeService() + ..setCredentials('https://musik.baka-net.de', 'Tinker', 'altesPasswort'); + + await tester.pumpWidget(_wrap(auth, + db: db, categories: categories, navidrome: nav)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Beim Baka-Konto anmelden')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).last, 'geheim '); + await tester.tap(find.text('Anmelden')); + await tester.pumpAndSettle(); + + expect(gesendetesPasswort, 'geheim'); + expect(nav.passwort, 'geheim'); + }); + testWidgets( 'Server-User meldet sich im Hintergrund mit denselben Zugangsdaten ' 'beim Baka-Konto an', (tester) async { -- 2.54.0