From 96d9f38a64964dda69e979b3a0a6d8c4bdaa4d2c Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Tue, 18 Aug 2026 17:19:56 +0200 Subject: [PATCH] feat(playlists): Playlist-Detailansicht mit Drag&Drop-Umsortieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlaylistDetailScreen zeigt die Songs einer Playlist (watchPlaylistSongs), erlaubt Entfernen einzelner Songs und Umsortieren per Drag & Drop (ReorderableListView.builder). Tippen auf einen Song spielt die Playlist ab dieser Stelle. Leer-Zustand mit Hinweistext. Da bestehende position-Werte nach Einfügereihenfolge lückenhaft sein können, schreibt das Umsortieren nicht nur den verschobenen Song um, sondern vergibt für die gesamte Playlist neue lückenlose Positionen (0..N-1): MeloDb.reorderAllPlaylistSongs (in einer Transaktion) + PlaylistService.reorderAll als dünner Wrapper. Co-Authored-By: Claude Haiku 4.5 --- lib/library/database.dart | 14 ++++ lib/library/playlist_service.dart | 5 ++ lib/playlists/playlist_detail_screen.dart | 81 +++++++++++++++++++++++ test/library/playlist_service_test.dart | 22 ++++++ 4 files changed, 122 insertions(+) create mode 100644 lib/playlists/playlist_detail_screen.dart diff --git a/lib/library/database.dart b/lib/library/database.dart index f7645ea..9171ee9 100644 --- a/lib/library/database.dart +++ b/lib/library/database.dart @@ -232,6 +232,20 @@ class MeloDb extends _$MeloDb { .write(PlaylistSongsCompanion(position: Value(newPosition))); } + /// Schreibt für die gesamte Playlist neue, lückenlose Positionen (0..N-1) + /// gemäß [orderedSongIds] — vermeidet Mehrdeutigkeiten beim Umsortieren, + /// da einzelne [reorderPlaylistSong]-Aufrufe nur einen Song verschieben. + Future reorderAllPlaylistSongs(String playlistId, List orderedSongIds) async { + await transaction(() async { + for (var i = 0; i < orderedSongIds.length; i++) { + await (update(playlistSongs) + ..where((ps) => + ps.playlistId.equals(playlistId) & ps.songId.equals(orderedSongIds[i]))) + .write(PlaylistSongsCompanion(position: Value(i))); + } + }); + } + // === Favorites === Future toggleFavorite(String songId) async { final existing = await (select(favorites)..where((f) => f.songId.equals(songId))).getSingleOrNull(); diff --git a/lib/library/playlist_service.dart b/lib/library/playlist_service.dart index 42f4e5a..0258dc2 100644 --- a/lib/library/playlist_service.dart +++ b/lib/library/playlist_service.dart @@ -34,6 +34,11 @@ class PlaylistService extends ChangeNotifier { notifyListeners(); } + Future reorderAll(String playlistId, List orderedSongIds) async { + await db.reorderAllPlaylistSongs(playlistId, orderedSongIds); + notifyListeners(); + } + Future toggleFavorite(String songId) async { await db.toggleFavorite(songId); notifyListeners(); diff --git a/lib/playlists/playlist_detail_screen.dart b/lib/playlists/playlist_detail_screen.dart new file mode 100644 index 0000000..5fd022c --- /dev/null +++ b/lib/playlists/playlist_detail_screen.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../library/database.dart'; +import '../library/playlist_service.dart'; +import '../library/song_media.dart'; +import '../player/audio_handler.dart'; +import '../shared/cover.dart'; + +/// Playlist-Detailansicht: Songs anzeigen, per Drag & Drop umsortieren, entfernen. +class PlaylistDetailScreen extends StatelessWidget { + const PlaylistDetailScreen({super.key, required this.playlist}); + final Playlist playlist; + + @override + Widget build(BuildContext context) { + final db = context.read(); + final handler = context.read(); + final service = context.read(); + return Scaffold( + appBar: AppBar(title: Text(playlist.name)), + body: StreamBuilder>( + stream: db.watchPlaylistSongs(playlist.id), + 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) { + return const Center( + child: Text('Playlist ist leer — füge Songs aus der Bibliothek hinzu', + style: TextStyle(color: Colors.white54)), + ); + } + return ReorderableListView.builder( + itemCount: songs.length, + onReorderItem: (oldIndex, newIndex) { + final reordered = List.from(songs); + final moved = reordered.removeAt(oldIndex); + reordered.insert(newIndex, moved); + service.reorderAll(playlist.id, reordered.map((s) => s.id).toList()); + }, + itemBuilder: (context, i) { + final s = songs[i]; + return ListTile( + key: ValueKey(s.id), + leading: CoverImage( + artUri: s.coverPath != null ? Uri.file(s.coverPath!) : null, + size: 48, + radius: 6, + ), + title: Text(s.title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text(s.artist ?? 'Unbekannt', + maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: IconButton( + tooltip: 'Aus Playlist entfernen', + icon: const Icon(Icons.remove_circle_outline), + onPressed: () => service.removeSongFromPlaylist(playlist.id, s.id), + ), + onTap: () async { + try { + await playSongs(handler, songs, i); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')), + ); + } + } + }, + ); + }, + ); + }, + ), + ); + } +} diff --git a/test/library/playlist_service_test.dart b/test/library/playlist_service_test.dart index 606a70d..2bbb0dd 100644 --- a/test/library/playlist_service_test.dart +++ b/test/library/playlist_service_test.dart @@ -39,4 +39,26 @@ void main() { expect(await db.watchIsFavorite('song-1').first, true); expect(notified, true); }); + + test('reorderAll schreibt lückenlose Positionen 0..N-1 in neuer Reihenfolge', () async { + final playlistId = await db.createPlaylist('Mix'); + for (final id in ['song-1', 'song-2', 'song-3']) { + await db.into(db.songs).insert(SongsCompanion.insert( + id: id, path: '/$id.mp3', title: id, dateAddedMs: 0, updatedAtMs: 0, + )); + } + // Ursprüngliche (evtl. lückenhafte) Positionen. + await db.addSongToPlaylist(playlistId, 'song-1', 0); + await db.addSongToPlaylist(playlistId, 'song-2', 5); + await db.addSongToPlaylist(playlistId, 'song-3', 9); + + var notified = false; + service.addListener(() => notified = true); + + await service.reorderAll(playlistId, ['song-3', 'song-1', 'song-2']); + + final songs = await db.watchPlaylistSongs(playlistId).first; + expect(songs.map((s) => s.id).toList(), ['song-3', 'song-1', 'song-2']); + expect(notified, true); + }); }