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