diff --git a/lib/models/library_collections.dart b/lib/models/library_collections.dart index 5d30b831..7bd51ae3 100644 --- a/lib/models/library_collections.dart +++ b/lib/models/library_collections.dart @@ -171,6 +171,28 @@ class UserPlaylistCollection { return _trackKeys.contains(trackKey); } + bool sharesMembershipIndexWith(UserPlaylistCollection other) => + identical(_trackKeys, other._trackKeys); + + /// Publishes tracks and their index built by the hydration worker. When it + /// verified unchanged membership, keep the original index so the enclosing + /// collections state need not rescan every other playlist's tracks. + UserPlaylistCollection withHydratedTracks({ + required List tracks, + required Set trackKeys, + required bool membershipUnchanged, + }) => UserPlaylistCollection( + id: id, + name: name, + coverImagePath: coverImagePath, + createdAt: createdAt, + updatedAt: updatedAt, + tracks: tracks, + previewCover: previewCover, + tracksLoaded: true, + trackKeys: membershipUnchanged ? _trackKeys : trackKeys, + ); + Set get trackKeys => UnmodifiableSetView(_trackKeys); int get trackCount => _trackKeys.length; @@ -365,10 +387,30 @@ class LibraryCollectionsState { lovedKeys: keepLovedIndex ? _lovedKeys : null, favoriteArtistKeys: keepFavoriteArtistIndex ? _favoriteArtistKeys : null, playlistsById: keepPlaylistIndex ? _playlistsById : null, - allPlaylistTrackKeys: keepPlaylistIndex ? _allPlaylistTrackKeys : null, + allPlaylistTrackKeys: + keepPlaylistIndex || _hasSamePlaylistMembership(nextPlaylists) + ? _allPlaylistTrackKeys + : null, ); } + bool _hasSamePlaylistMembership(List nextPlaylists) { + if (nextPlaylists.length != playlists.length || + _playlistsById.length != playlists.length) { + return false; + } + final seen = {}; + for (final playlist in nextPlaylists) { + final previous = _playlistsById[playlist.id]; + if (!seen.add(playlist.id) || + previous == null || + !identical(previous._trackKeys, playlist._trackKeys)) { + return false; + } + } + return true; + } + Map toJson() => { 'wishlist': wishlist.map((e) => e.toJson()).toList(), 'loved': loved.map((e) => e.toJson()).toList(), diff --git a/test/library_collections_models_test.dart b/test/library_collections_models_test.dart index c325f5bc..01087584 100644 --- a/test/library_collections_models_test.dart +++ b/test/library_collections_models_test.dart @@ -106,4 +106,37 @@ void main() { expect(loaded.tracksLoaded, isTrue); expect(loaded.trackCount, 0); }); + + test( + 'membership remains correct after metadata copies and playlist changes', + () { + UserPlaylistCollection playlist(String id, String key) => + UserPlaylistCollection( + id: id, + name: id, + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + tracks: const [], + tracksLoaded: false, + trackKeys: {key}, + ); + final first = playlist('first', 'example:first'); + final second = playlist('second', 'example:second'); + final state = LibraryCollectionsState(playlists: [first, second]); + final renamed = state.copyWith( + playlists: [ + second.copyWith(name: 'Renamed'), + first, + ], + ); + expect(renamed.isTrackInAnyPlaylist('example:first'), isTrue); + expect(renamed.isTrackInAnyPlaylist('example:second'), isTrue); + final replaced = renamed.copyWith(playlists: [first, first]); + expect(replaced.isTrackInAnyPlaylist('example:first'), isTrue); + expect(replaced.isTrackInAnyPlaylist('example:second'), isFalse); + final removed = renamed.copyWith(playlists: [second]); + expect(removed.isTrackInAnyPlaylist('example:first'), isFalse); + expect(removed.isTrackInAnyPlaylist('example:second'), isTrue); + }, + ); }