From 58ceb42769156d88c32852fe546a99391cca99d3 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Tue, 18 Aug 2026 17:34:41 +0200 Subject: [PATCH] feat(library): add Artist and Album browsing tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Library screen now has a TabBar (Alle / Künstler / Alben) inside a DefaultTabController; scan/permission/error banners stay above the TabBarView so they remain visible regardless of the active sub-tab - New lib/library/song_grouping.dart: pure, unit-tested functions groupByArtist/groupByAlbum (null artist/album -> "Unbekannt" / "Unbekanntes Album", alphabetically sorted) plus albumArtistLabel ("Verschiedene Interpreten" when an album mixes artists) - New lib/library/artist_list.dart + album_list.dart: StreamBuilder on MeloDb.watchSongs(), grouped client-side, tapping a row pushes a SongList screen for that artist/album; album rows show a CoverImage thumbnail from the first song's coverPath - Existing "Alle" tab content (recently-added strip, full song list, empty state) preserved unchanged, moved into _AllSongsTab Genre browsing is intentionally out of scope: the Songs table has no genre column and neither scanner extracts genre metadata — needs a schema migration + scanner work in a future task. Co-Authored-By: Claude Haiku 4.5 --- lib/library/album_list.dart | 69 ++++++++++ lib/library/artist_list.dart | 61 ++++++++ lib/library/library_screen.dart | 191 ++++++++++++++++---------- lib/library/song_grouping.dart | 38 +++++ test/library/library_screen_test.dart | 110 +++++++++++++++ test/library/song_grouping_test.dart | 112 +++++++++++++++ 6 files changed, 505 insertions(+), 76 deletions(-) create mode 100644 lib/library/album_list.dart create mode 100644 lib/library/artist_list.dart create mode 100644 lib/library/song_grouping.dart create mode 100644 test/library/library_screen_test.dart create mode 100644 test/library/song_grouping_test.dart diff --git a/lib/library/album_list.dart b/lib/library/album_list.dart new file mode 100644 index 0000000..de82dca --- /dev/null +++ b/lib/library/album_list.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../shared/cover.dart'; +import 'database.dart'; +import 'song_grouping.dart'; +import 'song_list.dart'; + +/// Bibliotheks-Tab: Album-Übersicht, gruppiert aus [MeloDb.watchSongs]. +class AlbumListScreen extends StatelessWidget { + const AlbumListScreen({super.key}); + + @override + Widget build(BuildContext context) { + final db = context.read(); + return StreamBuilder>( + stream: db.watchSongs(), + builder: (context, snapshot) { + final songs = snapshot.data ?? const []; + if (songs.isEmpty) { + return const Center( + child: Text('Keine Alben', style: TextStyle(color: Colors.white54)), + ); + } + final grouped = groupByAlbum(songs); + final albums = grouped.keys.toList(); + return ListView.builder( + itemCount: albums.length, + itemBuilder: (context, i) { + final album = albums[i]; + final albumSongs = grouped[album]!; + final coverPath = albumSongs.first.coverPath; + return ListTile( + leading: CoverImage( + artUri: coverPath != null ? Uri.file(coverPath) : null, + size: 48, + radius: 6, + ), + title: Text(album, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text('${albumArtistLabel(albumSongs)} · ${albumSongs.length} Songs', + maxLines: 1, overflow: TextOverflow.ellipsis), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => _AlbumSongsScreen(album: album, songs: albumSongs), + ), + ), + ); + }, + ); + }, + ); + } +} + +/// Zeigt alle Songs eines Albums. +class _AlbumSongsScreen extends StatelessWidget { + const _AlbumSongsScreen({required this.album, required this.songs}); + final String album; + final List songs; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(album)), + body: SongList(songs), + ); + } +} diff --git a/lib/library/artist_list.dart b/lib/library/artist_list.dart new file mode 100644 index 0000000..acdd271 --- /dev/null +++ b/lib/library/artist_list.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'database.dart'; +import 'song_grouping.dart'; +import 'song_list.dart'; + +/// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs]. +class ArtistListScreen extends StatelessWidget { + const ArtistListScreen({super.key}); + + @override + Widget build(BuildContext context) { + final db = context.read(); + return StreamBuilder>( + stream: db.watchSongs(), + builder: (context, snapshot) { + final songs = snapshot.data ?? const []; + if (songs.isEmpty) { + return const Center( + child: Text('Keine Künstler', style: TextStyle(color: Colors.white54)), + ); + } + final grouped = groupByArtist(songs); + final artists = grouped.keys.toList(); + return ListView.builder( + itemCount: artists.length, + itemBuilder: (context, i) { + final artist = artists[i]; + final artistSongs = grouped[artist]!; + return ListTile( + title: Text(artist, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text('${artistSongs.length} Songs'), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => _ArtistSongsScreen(artist: artist, songs: artistSongs), + ), + ), + ); + }, + ); + }, + ); + } +} + +/// Zeigt alle Songs eines Künstlers. +class _ArtistSongsScreen extends StatelessWidget { + const _ArtistSongsScreen({required this.artist, required this.songs}); + final String artist; + final List songs; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(artist)), + body: SongList(songs), + ); + } +} diff --git a/lib/library/library_screen.dart b/lib/library/library_screen.dart index 308378e..1fed9ac 100644 --- a/lib/library/library_screen.dart +++ b/lib/library/library_screen.dart @@ -3,103 +3,142 @@ import 'package:provider/provider.dart'; import '../player/audio_handler.dart'; import '../shared/cover.dart'; +import 'album_list.dart'; +import 'artist_list.dart'; import 'database.dart'; import 'library_service.dart'; import 'permissions.dart'; import 'song_list.dart'; import 'song_media.dart'; +/// Bibliotheks-Tab mit drei Unteransichten: alle Songs, Künstler, Alben. +/// Scan-Banner/-Fortschritt gelten für alle Unteransichten und liegen daher +/// oberhalb der [TabBarView]. class LibraryScreen extends StatelessWidget { const LibraryScreen({super.key}); @override Widget build(BuildContext context) { - final db = context.read(); final lib = context.watch(); - return Scaffold( - appBar: AppBar( - title: const Text('Bibliothek'), - actions: [ - IconButton( - tooltip: addMusicLabel, - icon: const Icon(Icons.create_new_folder_outlined), - onPressed: lib.scanning ? null : lib.pickFolderAndScan, - ), - IconButton( - tooltip: 'Erneut scannen', - icon: const Icon(Icons.refresh), - onPressed: lib.scanning ? null : lib.rescan, - ), - ], - ), - body: Column( - children: [ - if (lib.permissionDenied) - MaterialBanner( - backgroundColor: Colors.red.shade900, - content: const Text('Zugriff auf Musik verweigert. ' - 'Bitte in den Einstellungen erlauben.'), - actions: [ - TextButton( - onPressed: openMusicPermissionSettings, - child: const Text('Einstellungen'), - ), - ], + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('Bibliothek'), + actions: [ + IconButton( + tooltip: addMusicLabel, + icon: const Icon(Icons.create_new_folder_outlined), + onPressed: lib.scanning ? null : lib.pickFolderAndScan, ), - if (lib.scanError != null) - MaterialBanner( - backgroundColor: Colors.red.shade900, - content: Text(lib.scanError!), - actions: [ - TextButton( - onPressed: lib.dismissScanError, - child: const Text('Verwerfen'), - ), - ], + IconButton( + tooltip: 'Erneut scannen', + icon: const Icon(Icons.refresh), + onPressed: lib.scanning ? null : lib.rescan, ), - if (lib.scanning) - Padding( - padding: const EdgeInsets.all(12), - child: Column( - children: [ - const LinearProgressIndicator(), - const SizedBox(height: 6), - Text( - 'Scanne … ${lib.scanDone}' - '${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}', - style: const TextStyle(color: Colors.white54, fontSize: 12), + ], + bottom: const TabBar( + tabs: [ + Tab(text: 'Alle'), + Tab(text: 'Künstler'), + Tab(text: 'Alben'), + ], + ), + ), + body: Column( + children: [ + if (lib.permissionDenied) + MaterialBanner( + backgroundColor: Colors.red.shade900, + content: const Text('Zugriff auf Musik verweigert. ' + 'Bitte in den Einstellungen erlauben.'), + actions: [ + TextButton( + onPressed: openMusicPermissionSettings, + child: const Text('Einstellungen'), ), ], ), + if (lib.scanError != null) + MaterialBanner( + backgroundColor: Colors.red.shade900, + content: Text(lib.scanError!), + actions: [ + TextButton( + onPressed: lib.dismissScanError, + child: const Text('Verwerfen'), + ), + ], + ), + if (lib.scanning) + Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + const LinearProgressIndicator(), + const SizedBox(height: 6), + Text( + 'Scanne … ${lib.scanDone}' + '${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}', + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ), + ), + const Expanded( + child: TabBarView( + children: [ + _AllSongsTab(), + ArtistListScreen(), + AlbumListScreen(), + ], + ), ), - StreamBuilder>( - stream: db.watchRecent(limit: 10), + ], + ), + ), + ); + } +} + +/// "Alle"-Tab: zuletzt hinzugefügt + vollständige Songliste (unverändertes +/// Verhalten aus der Zeit vor der Künstler-/Alben-Aufteilung). +class _AllSongsTab extends StatelessWidget { + const _AllSongsTab(); + + @override + Widget build(BuildContext context) { + final db = context.read(); + final lib = context.watch(); + return Column( + children: [ + StreamBuilder>( + stream: db.watchRecent(limit: 10), + builder: (context, snapshot) { + final recent = snapshot.data ?? const []; + if (recent.isEmpty || lib.scanning) return const SizedBox.shrink(); + return _RecentlyAdded(songs: recent); + }, + ), + Expanded( + child: StreamBuilder>( + stream: db.watchSongs(), builder: (context, snapshot) { - final recent = snapshot.data ?? const []; - if (recent.isEmpty || lib.scanning) return const SizedBox.shrink(); - return _RecentlyAdded(songs: recent); + if (snapshot.hasError) { + return Center( + child: Text('Fehler: ${snapshot.error}', + style: const TextStyle(color: Colors.white54)), + ); + } + final songs = snapshot.data ?? const []; + if (songs.isEmpty && !lib.scanning) { + return const _Empty(); + } + return SongList(songs); }, ), - Expanded( - child: StreamBuilder>( - stream: db.watchSongs(), - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text('Fehler: ${snapshot.error}', - style: const TextStyle(color: Colors.white54)), - ); - } - final songs = snapshot.data ?? const []; - if (songs.isEmpty && !lib.scanning) { - return const _Empty(); - } - return SongList(songs); - }, - ), - ), - ], - ), + ), + ], ); } } diff --git a/lib/library/song_grouping.dart b/lib/library/song_grouping.dart new file mode 100644 index 0000000..276e2e1 --- /dev/null +++ b/lib/library/song_grouping.dart @@ -0,0 +1,38 @@ +import 'database.dart'; + +const unbekannterKuenstler = 'Unbekannt'; +const unbekanntesAlbum = 'Unbekanntes Album'; + +/// Gruppiert [songs] nach Künstler (fehlender Künstler → [unbekannterKuenstler]), +/// alphabetisch nach Künstlername sortiert. +Map> groupByArtist(List songs) { + return _groupBy(songs, (s) => s.artist, unbekannterKuenstler); +} + +/// Gruppiert [songs] nach Album (fehlendes Album → [unbekanntesAlbum]), +/// alphabetisch nach Albumname sortiert. +Map> groupByAlbum(List songs) { + return _groupBy(songs, (s) => s.album, unbekanntesAlbum); +} + +/// Künstler-Beschriftung für ein Album: der gemeinsame Künstler, falls alle +/// Songs im Album vom selben Künstler stammen, sonst "Verschiedene Interpreten". +String albumArtistLabel(List songs) { + final artists = songs.map((s) => s.artist ?? unbekannterKuenstler).toSet(); + return artists.length == 1 ? artists.first : 'Verschiedene Interpreten'; +} + +Map> _groupBy( + List songs, + String? Function(Song) keyOf, + String fallback, +) { + final grouped = >{}; + for (final song in songs) { + final key = keyOf(song) ?? fallback; + grouped.putIfAbsent(key, () => []).add(song); + } + final sortedKeys = grouped.keys.toList() + ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return {for (final key in sortedKeys) key: grouped[key]!}; +} diff --git a/test/library/library_screen_test.dart b/test/library/library_screen_test.dart new file mode 100644 index 0000000..3f19263 --- /dev/null +++ b/test/library/library_screen_test.dart @@ -0,0 +1,110 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/library_screen.dart'; +import 'package:melo/library/library_service.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/player/audio_handler.dart'; + +Widget _wrap( + MeloDb db, + LibraryService lib, + PlaylistService playlists, + MeloAudioHandler handler, + Widget child, +) { + return MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: lib), + ChangeNotifierProvider.value(value: playlists), + Provider.value(value: handler), + ], + child: MaterialApp(home: child), + ); +} + +Future _insertSong( + MeloDb db, { + required String id, + required String title, + String? artist, + String? album, +}) { + return db.into(db.songs).insert(SongsCompanion.insert( + id: id, + path: '/$id.mp3', + title: title, + artist: Value(artist), + album: Value(album), + dateAddedMs: 0, + updatedAtMs: 0, + )); +} + +void main() { + testWidgets('zeigt die drei Bibliotheks-Tabs "Alle", "Künstler", "Alben"', + (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final lib = LibraryService(db); + final playlists = PlaylistService(db); + final handler = MeloAudioHandler(); + + await tester.pumpWidget( + _wrap(db, lib, playlists, handler, const LibraryScreen()), + ); + await tester.pumpAndSettle(); + + expect(find.text('Alle'), findsOneWidget); + expect(find.text('Künstler'), findsOneWidget); + expect(find.text('Alben'), findsOneWidget); + + await db.close(); + }); + + testWidgets('Künstler-Tab zeigt je Künstler eine Zeile', (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final lib = LibraryService(db); + final playlists = PlaylistService(db); + final handler = MeloAudioHandler(); + await _insertSong(db, id: '1', title: 'Song A', artist: 'Alice'); + await _insertSong(db, id: '2', title: 'Song B', artist: 'Bob'); + + await tester.pumpWidget( + _wrap(db, lib, playlists, handler, const LibraryScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Künstler')); + await tester.pumpAndSettle(); + + expect(find.text('Alice'), findsOneWidget); + expect(find.text('Bob'), findsOneWidget); + expect(find.text('1 Songs'), findsNWidgets(2)); + + await db.close(); + }); + + testWidgets('Alben-Tab zeigt je Album eine Zeile', (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final lib = LibraryService(db); + final playlists = PlaylistService(db); + final handler = MeloAudioHandler(); + await _insertSong(db, id: '1', title: 'Song A', artist: 'Alice', album: 'Best Of'); + + await tester.pumpWidget( + _wrap(db, lib, playlists, handler, const LibraryScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Alben')); + await tester.pumpAndSettle(); + + expect(find.text('Best Of'), findsOneWidget); + + await db.close(); + }); +} diff --git a/test/library/song_grouping_test.dart b/test/library/song_grouping_test.dart new file mode 100644 index 0000000..553553a --- /dev/null +++ b/test/library/song_grouping_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/song_grouping.dart'; + +Song _song({ + required String id, + required String title, + String? artist, + String? album, +}) { + return Song( + id: id, + path: '/$id.mp3', + title: title, + artist: artist, + album: album, + dateAddedMs: 0, + updatedAtMs: 0, + deleted: false, + ); +} + +void main() { + group('groupByArtist', () { + test('gruppiert mehrere Künstler getrennt', () { + final songs = [ + _song(id: '1', title: 'A', artist: 'Bob'), + _song(id: '2', title: 'B', artist: 'Alice'), + _song(id: '3', title: 'C', artist: 'Alice'), + ]; + + final grouped = groupByArtist(songs); + + expect(grouped.keys, ['Alice', 'Bob']); + expect(grouped['Alice']!.length, 2); + expect(grouped['Bob']!.length, 1); + }); + + test('gruppiert Songs ohne Künstler unter "Unbekannt"', () { + final songs = [ + _song(id: '1', title: 'A', artist: null), + _song(id: '2', title: 'B', artist: null), + ]; + + final grouped = groupByArtist(songs); + + expect(grouped.keys, [unbekannterKuenstler]); + expect(grouped[unbekannterKuenstler]!.length, 2); + }); + + test('sortiert Künstlernamen alphabetisch', () { + final songs = [ + _song(id: '1', title: 'A', artist: 'Zebra'), + _song(id: '2', title: 'B', artist: 'Anton'), + _song(id: '3', title: 'C', artist: 'Mitte'), + ]; + + final grouped = groupByArtist(songs); + + expect(grouped.keys.toList(), ['Anton', 'Mitte', 'Zebra']); + }); + + test('leere Liste ergibt leere Map', () { + expect(groupByArtist(const []), isEmpty); + }); + }); + + group('groupByAlbum', () { + test('gruppiert mehrere Alben getrennt', () { + final songs = [ + _song(id: '1', title: 'A', album: 'Best Of'), + _song(id: '2', title: 'B', album: 'Anthology'), + ]; + + final grouped = groupByAlbum(songs); + + expect(grouped.keys.toList(), ['Anthology', 'Best Of']); + }); + + test('gruppiert Songs ohne Album unter "Unbekanntes Album"', () { + final songs = [_song(id: '1', title: 'A', album: null)]; + + final grouped = groupByAlbum(songs); + + expect(grouped.keys, [unbekanntesAlbum]); + }); + + test('leere Liste ergibt leere Map', () { + expect(groupByAlbum(const []), isEmpty); + }); + }); + + group('albumArtistLabel', () { + test('zeigt gemeinsamen Künstler, wenn alle Songs von ihm stammen', () { + final songs = [ + _song(id: '1', title: 'A', artist: 'Alice', album: 'X'), + _song(id: '2', title: 'B', artist: 'Alice', album: 'X'), + ]; + + expect(albumArtistLabel(songs), 'Alice'); + }); + + test('zeigt "Verschiedene Interpreten" bei unterschiedlichen Künstlern', () { + final songs = [ + _song(id: '1', title: 'A', artist: 'Alice', album: 'X'), + _song(id: '2', title: 'B', artist: 'Bob', album: 'X'), + ]; + + expect(albumArtistLabel(songs), 'Verschiedene Interpreten'); + }); + }); +}