import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/library_collections_database.dart'; const _playlistCoverMaxDimension = 1024; const _playlistCoverMaxStoredBytes = 2 * 1024 * 1024; String trackCollectionKey(Track track) { final isrc = track.isrc?.trim(); if (isrc != null && isrc.isNotEmpty) { return 'isrc:${isrc.toUpperCase()}'; } final source = (track.source?.trim().isNotEmpty ?? false) ? track.source!.trim() : 'builtin'; return '$source:${track.id}'; } String _stripCollectionResourcePrefix(String value) { final colonIndex = value.indexOf(':'); if (colonIndex <= 0 || colonIndex == value.length - 1) { return value.trim(); } return value.substring(colonIndex + 1).trim(); } String artistCollectionKey({ required String artistId, required String? providerId, }) { final trimmedArtistId = artistId.trim(); final trimmedProviderId = providerId?.trim(); final source = trimmedProviderId != null && trimmedProviderId.isNotEmpty ? trimmedProviderId.toLowerCase() : (trimmedArtistId.contains(':') ? trimmedArtistId.split(':').first.toLowerCase() : 'builtin'); return '$source:${_stripCollectionResourcePrefix(trimmedArtistId)}'; } class CollectionTrackEntry { final String key; final Track track; final DateTime addedAt; const CollectionTrackEntry({ required this.key, required this.track, required this.addedAt, }); Map toJson() => { 'key': key, 'track': track.toJson(), 'addedAt': addedAt.toIso8601String(), }; factory CollectionTrackEntry.fromJson(Map json) { final addedAtRaw = json['addedAt'] as String?; return CollectionTrackEntry( key: json['key'] as String, track: Track.fromJson(Map.from(json['track'] as Map)), addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), ); } } class CollectionArtistEntry { final String key; final String artistId; final String? providerId; final String name; final String? imageUrl; final DateTime addedAt; const CollectionArtistEntry({ required this.key, required this.artistId, required this.providerId, required this.name, this.imageUrl, required this.addedAt, }); Map toJson() => { 'key': key, 'artistId': artistId, 'providerId': providerId, 'name': name, 'imageUrl': imageUrl, 'addedAt': addedAt.toIso8601String(), }; factory CollectionArtistEntry.fromJson(Map json) { final artistId = json['artistId'] as String; final providerId = json['providerId'] as String?; final addedAtRaw = json['addedAt'] as String?; return CollectionArtistEntry( key: json['key'] as String? ?? artistCollectionKey(artistId: artistId, providerId: providerId), artistId: artistId, providerId: providerId, name: json['name'] as String? ?? '', imageUrl: json['imageUrl'] as String?, addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), ); } } class UserPlaylistCollection { final String id; final String name; final String? coverImagePath; final DateTime createdAt; final DateTime updatedAt; final List tracks; final String? previewCover; final bool tracksLoaded; final Set _trackKeys; UserPlaylistCollection({ required this.id, required this.name, this.coverImagePath, required this.createdAt, required this.updatedAt, required this.tracks, this.previewCover, this.tracksLoaded = true, Set? trackKeys, }) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet(); UserPlaylistCollection copyWith({ String? id, String? name, String? Function()? coverImagePath, DateTime? createdAt, DateTime? updatedAt, List? tracks, String? previewCover, bool? tracksLoaded, }) { final nextTracks = tracks ?? this.tracks; final keepTrackIndex = identical(nextTracks, this.tracks); return UserPlaylistCollection( id: id ?? this.id, name: name ?? this.name, coverImagePath: coverImagePath != null ? coverImagePath() : this.coverImagePath, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, tracks: nextTracks, previewCover: previewCover ?? this.previewCover, tracksLoaded: tracksLoaded ?? (identical(nextTracks, this.tracks) ? this.tracksLoaded : true), trackKeys: keepTrackIndex ? _trackKeys : null, ); } bool containsTrack(Track track) { final key = trackCollectionKey(track); return _trackKeys.contains(key); } bool containsTrackKey(String trackKey) { return _trackKeys.contains(trackKey); } int get trackCount => _trackKeys.length; Map toJson() => { 'id': id, 'name': name, if (coverImagePath != null) 'coverImagePath': coverImagePath, 'createdAt': createdAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(), 'tracks': tracks.map((e) => e.toJson()).toList(), }; factory UserPlaylistCollection.fromJson(Map json) { final createdAtRaw = json['createdAt'] as String?; final updatedAtRaw = json['updatedAt'] as String?; final createdAt = DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now(); final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt; final tracksRaw = (json['tracks'] as List?) ?? const []; return UserPlaylistCollection( id: json['id'] as String, name: json['name'] as String? ?? '', coverImagePath: json['coverImagePath'] as String?, createdAt: createdAt, updatedAt: updatedAt, tracks: tracksRaw .whereType>() .map( (e) => CollectionTrackEntry.fromJson(Map.from(e)), ) .toList(growable: false), ); } } class PlaylistPickerSummary { final String id; final String name; final String? coverImagePath; final String? previewCover; final DateTime createdAt; final DateTime updatedAt; final int trackCount; final bool containsAllRequestedTracks; const PlaylistPickerSummary({ required this.id, required this.name, this.coverImagePath, this.previewCover, required this.createdAt, required this.updatedAt, required this.trackCount, required this.containsAllRequestedTracks, }); } class PlaylistPickerSummaryRequest { final List trackKeys; PlaylistPickerSummaryRequest._(this.trackKeys); factory PlaylistPickerSummaryRequest.fromTracks(Iterable tracks) { final keys = tracks .map(trackCollectionKey) .where((key) => key.trim().isNotEmpty) .toSet() .toList(growable: false) ..sort(); return PlaylistPickerSummaryRequest._(keys); } @override bool operator ==(Object other) => identical(this, other) || other is PlaylistPickerSummaryRequest && listEquals(trackKeys, other.trackKeys); @override int get hashCode => Object.hashAll(trackKeys); } class LibraryCollectionsState { final List wishlist; final List loved; final List playlists; final List favoriteArtists; final bool isLoaded; final Set _wishlistKeys; final Set _lovedKeys; final Set _favoriteArtistKeys; final Map _playlistsById; final Set _allPlaylistTrackKeys; LibraryCollectionsState({ this.wishlist = const [], this.loved = const [], this.playlists = const [], this.favoriteArtists = const [], this.isLoaded = false, Set? wishlistKeys, Set? lovedKeys, Set? favoriteArtistKeys, Map? playlistsById, Set? allPlaylistTrackKeys, }) : _wishlistKeys = wishlistKeys ?? wishlist.map((entry) => entry.key).toSet(), _lovedKeys = lovedKeys ?? loved.map((entry) => entry.key).toSet(), _favoriteArtistKeys = favoriteArtistKeys ?? favoriteArtists.map((entry) => entry.key).toSet(), _playlistsById = playlistsById ?? Map.fromEntries( playlists.map((playlist) => MapEntry(playlist.id, playlist)), ), _allPlaylistTrackKeys = allPlaylistTrackKeys ?? _buildPlaylistTrackKeys(playlists); int get wishlistCount => wishlist.length; int get lovedCount => loved.length; int get playlistCount => playlists.length; int get favoriteArtistCount => favoriteArtists.length; bool isInWishlist(Track track) { final key = trackCollectionKey(track); return _wishlistKeys.contains(key); } bool isLoved(Track track) { final key = trackCollectionKey(track); return _lovedKeys.contains(key); } bool containsWishlistKey(String trackKey) { return _wishlistKeys.contains(trackKey); } bool containsLovedKey(String trackKey) { return _lovedKeys.contains(trackKey); } bool isFavoriteArtist({ required String artistId, required String? providerId, }) { final key = artistCollectionKey(artistId: artistId, providerId: providerId); return _favoriteArtistKeys.contains(key); } bool containsFavoriteArtistKey(String artistKey) { return _favoriteArtistKeys.contains(artistKey); } UserPlaylistCollection? playlistById(String playlistId) { return _playlistsById[playlistId]; } bool playlistContainsTrack(String playlistId, String trackKey) { final playlist = _playlistsById[playlistId]; if (playlist == null) return false; return playlist.containsTrackKey(trackKey); } bool isTrackInAnyPlaylist(String trackKey) { return _allPlaylistTrackKeys.contains(trackKey); } bool get hasPlaylistTracks => _allPlaylistTrackKeys.isNotEmpty; LibraryCollectionsState copyWith({ List? wishlist, List? loved, List? playlists, List? favoriteArtists, bool? isLoaded, }) { final nextWishlist = wishlist ?? this.wishlist; final nextLoved = loved ?? this.loved; final nextPlaylists = playlists ?? this.playlists; final nextFavoriteArtists = favoriteArtists ?? this.favoriteArtists; final keepWishlistIndex = identical(nextWishlist, this.wishlist); final keepLovedIndex = identical(nextLoved, this.loved); final keepPlaylistIndex = identical(nextPlaylists, this.playlists); final keepFavoriteArtistIndex = identical( nextFavoriteArtists, this.favoriteArtists, ); return LibraryCollectionsState( wishlist: nextWishlist, loved: nextLoved, playlists: nextPlaylists, favoriteArtists: nextFavoriteArtists, isLoaded: isLoaded ?? this.isLoaded, wishlistKeys: keepWishlistIndex ? _wishlistKeys : null, lovedKeys: keepLovedIndex ? _lovedKeys : null, favoriteArtistKeys: keepFavoriteArtistIndex ? _favoriteArtistKeys : null, playlistsById: keepPlaylistIndex ? _playlistsById : null, allPlaylistTrackKeys: keepPlaylistIndex ? _allPlaylistTrackKeys : null, ); } Map toJson() => { 'wishlist': wishlist.map((e) => e.toJson()).toList(), 'loved': loved.map((e) => e.toJson()).toList(), 'playlists': playlists.map((e) => e.toJson()).toList(), 'favoriteArtists': favoriteArtists.map((e) => e.toJson()).toList(), }; factory LibraryCollectionsState.fromJson(Map json) { final wishlistRaw = (json['wishlist'] as List?) ?? const []; final lovedRaw = (json['loved'] as List?) ?? const []; final playlistsRaw = (json['playlists'] as List?) ?? const []; final favoriteArtistsRaw = (json['favoriteArtists'] as List?) ?? const []; return LibraryCollectionsState( wishlist: wishlistRaw .whereType>() .map( (e) => CollectionTrackEntry.fromJson(Map.from(e)), ) .toList(growable: false), loved: lovedRaw .whereType>() .map( (e) => CollectionTrackEntry.fromJson(Map.from(e)), ) .toList(growable: false), playlists: playlistsRaw .whereType>() .map( (e) => UserPlaylistCollection.fromJson(Map.from(e)), ) .toList(growable: false), favoriteArtists: favoriteArtistsRaw .whereType>() .map( (e) => CollectionArtistEntry.fromJson(Map.from(e)), ) .toList(growable: false), isLoaded: true, ); } } Set _buildPlaylistTrackKeys(List playlists) { final keys = {}; for (final playlist in playlists) { keys.addAll(playlist._trackKeys); } return keys; } class PlaylistAddBatchResult { final int addedCount; final int alreadyInPlaylistCount; const PlaylistAddBatchResult({ required this.addedCount, required this.alreadyInPlaylistCount, }); } class LibraryCollectionsNotifier extends Notifier { final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance; Future? _loadFuture; final Map> _playlistLoadFutures = {}; void _invalidatePlaylistPickerSummaries() { ref.invalidate(libraryPlaylistPickerSummariesProvider); } @override LibraryCollectionsState build() { _loadFuture = _load(); return LibraryCollectionsState(); } Future _load() async { try { await _db.migrateFromSharedPreferences(); final snapshot = await _db.loadSnapshot(); final wishlist = []; for (final row in snapshot.wishlistRows) { final parsed = _parseTrackEntryRow(row); if (parsed != null) { wishlist.add(parsed); } } final loved = []; for (final row in snapshot.lovedRows) { final parsed = _parseTrackEntryRow(row); if (parsed != null) { loved.add(parsed); } } final favoriteArtists = []; for (final row in snapshot.favoriteArtistRows) { final parsed = _parseArtistEntryRow(row); if (parsed != null) { favoriteArtists.add(parsed); } } final trackKeysByPlaylist = >{}; for (final row in snapshot.playlistTrackRows) { final playlistId = row['playlist_id'] as String?; if (playlistId == null || playlistId.isEmpty) continue; final trackKey = row['track_key'] as String?; if (trackKey == null || trackKey.isEmpty) continue; trackKeysByPlaylist.putIfAbsent(playlistId, () => {}).add(trackKey); } final playlists = []; for (final row in snapshot.playlistRows) { final id = row['id'] as String?; if (id == null || id.isEmpty) continue; final createdAtRaw = row['created_at'] as String?; final updatedAtRaw = row['updated_at'] as String?; final createdAt = DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now(); final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt; String? previewCover; final previewTrackJson = row['preview_track_json'] as String?; if (previewTrackJson != null && previewTrackJson.isNotEmpty) { try { final decoded = jsonDecode(previewTrackJson); if (decoded is Map) { previewCover = decoded['coverUrl']?.toString(); } } catch (_) {} } playlists.add( UserPlaylistCollection( id: id, name: row['name'] as String? ?? '', coverImagePath: row['cover_image_path'] as String?, createdAt: createdAt, updatedAt: updatedAt, tracks: const [], previewCover: previewCover, tracksLoaded: false, trackKeys: trackKeysByPlaylist[id], ), ); } state = LibraryCollectionsState( wishlist: wishlist, loved: loved, playlists: playlists, favoriteArtists: favoriteArtists, isLoaded: true, ); } catch (_) { state = state.copyWith(isLoaded: true); } } Future _ensureLoaded() async { if (state.isLoaded) return; await (_loadFuture ?? _load()); } Future ensurePlaylistLoaded(String playlistId) async { await _ensureLoaded(); final playlist = state.playlistById(playlistId); if (playlist == null || playlist.tracksLoaded) return; final pending = _playlistLoadFutures[playlistId]; if (pending != null) return pending; final load = () async { final rows = await _db.loadPlaylistTracks(playlistId); final tracks = rows .map(_parseTrackEntryRow) .whereType() .toList(growable: false); _replacePlaylistById( playlistId, (current) => current.copyWith(tracks: tracks, tracksLoaded: true), ); }(); _playlistLoadFutures[playlistId] = load; try { await load; } finally { if (identical(_playlistLoadFutures[playlistId], load)) { _playlistLoadFutures.remove(playlistId); } } } Future ensurePlaylistsLoaded(Iterable playlistIds) async { await Future.wait(playlistIds.toSet().map(ensurePlaylistLoaded)); } CollectionTrackEntry? _parseTrackEntryRow(Map row) { final key = row['track_key'] as String?; final trackJson = row['track_json'] as String?; if (key == null || key.isEmpty || trackJson == null || trackJson.isEmpty) { return null; } try { final decoded = jsonDecode(trackJson); if (decoded is! Map) return null; final track = Track.fromJson(Map.from(decoded)); final addedAtRaw = row['added_at'] as String?; return CollectionTrackEntry( key: key, track: track, addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), ); } catch (_) { return null; } } CollectionArtistEntry? _parseArtistEntryRow(Map row) { final key = row['artist_key'] as String?; final artistJson = row['artist_json'] as String?; if (key == null || key.isEmpty || artistJson == null || artistJson.isEmpty) { return null; } try { final decoded = jsonDecode(artistJson); if (decoded is! Map) return null; final map = Map.from(decoded); final addedAtRaw = row['added_at'] as String?; return CollectionArtistEntry.fromJson({ ...map, 'key': key, 'addedAt': map['addedAt'] ?? addedAtRaw, }); } catch (_) { return null; } } bool _replacePlaylistById( String playlistId, UserPlaylistCollection Function(UserPlaylistCollection playlist) update, ) { final playlist = state.playlistById(playlistId); if (playlist == null) return false; final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId); if (playlistIndex < 0) return false; final nextPlaylist = update(playlist); if (identical(nextPlaylist, playlist)) return false; final updatedPlaylists = [...state.playlists]; updatedPlaylists[playlistIndex] = nextPlaylist; state = state.copyWith(playlists: updatedPlaylists); return true; } Future _toggleTrackEntry( Track track, { required bool Function(String key) contains, required List Function(LibraryCollectionsState state) select, required LibraryCollectionsState Function(List list) withList, required Future Function(String key) dbDelete, required Future Function({ required String trackKey, required String trackJson, required String addedAt, }) dbUpsert, }) async { await _ensureLoaded(); final key = trackCollectionKey(track); if (contains(key)) { await dbDelete(key); state = withList( select( state, ).where((entry) => entry.key != key).toList(growable: false), ); return false; } final entry = CollectionTrackEntry( key: key, track: track, addedAt: DateTime.now(), ); await dbUpsert( trackKey: key, trackJson: jsonEncode(track.toJson()), addedAt: entry.addedAt.toIso8601String(), ); state = withList([entry, ...select(state)]); return true; } Future toggleWishlist(Track track) => _toggleTrackEntry( track, contains: (key) => state.containsWishlistKey(key), select: (state) => state.wishlist, withList: (list) => state.copyWith(wishlist: list), dbDelete: _db.deleteWishlistEntry, dbUpsert: _db.upsertWishlistEntry, ); Future toggleLoved(Track track) => _toggleTrackEntry( track, contains: (key) => state.containsLovedKey(key), select: (state) => state.loved, withList: (list) => state.copyWith(loved: list), dbDelete: _db.deleteLovedEntry, dbUpsert: _db.upsertLovedEntry, ); Future toggleFavoriteArtist({ required String artistId, required String? providerId, required String name, String? imageUrl, }) async { await _ensureLoaded(); final key = artistCollectionKey(artistId: artistId, providerId: providerId); final sourceSeparator = key.indexOf(':'); final source = sourceSeparator > 0 ? key.substring(0, sourceSeparator) : ''; final trimmedProviderId = providerId?.trim(); final effectiveProviderId = trimmedProviderId != null && trimmedProviderId.isNotEmpty ? trimmedProviderId : (source.isNotEmpty && source != 'builtin' ? source : null); if (state.containsFavoriteArtistKey(key)) { await removeFavoriteArtist(key); return false; } final entry = CollectionArtistEntry( key: key, artistId: _stripCollectionResourcePrefix(artistId), providerId: effectiveProviderId, name: name, imageUrl: imageUrl, addedAt: DateTime.now(), ); await _db.upsertFavoriteArtistEntry( artistKey: key, artistJson: jsonEncode(entry.toJson()), addedAt: entry.addedAt.toIso8601String(), ); final updated = [entry, ...state.favoriteArtists]; state = state.copyWith(favoriteArtists: updated); return true; } Future _removeEntry( String key, { required bool Function(String key) contains, required List Function(LibraryCollectionsState state) select, required String Function(T entry) keyOf, required LibraryCollectionsState Function(List list) withList, required Future Function(String key) dbDelete, }) async { await _ensureLoaded(); if (!contains(key)) return; await dbDelete(key); state = withList( select( state, ).where((entry) => keyOf(entry) != key).toList(growable: false), ); } Future removeFavoriteArtist(String artistKey) => _removeEntry( artistKey, contains: (key) => state.containsFavoriteArtistKey(key), select: (state) => state.favoriteArtists, keyOf: (entry) => entry.key, withList: (list) => state.copyWith(favoriteArtists: list), dbDelete: _db.deleteFavoriteArtistEntry, ); Future removeFromWishlist(String trackKey) => _removeEntry( trackKey, contains: (key) => state.containsWishlistKey(key), select: (state) => state.wishlist, keyOf: (entry) => entry.key, withList: (list) => state.copyWith(wishlist: list), dbDelete: _db.deleteWishlistEntry, ); Future removeFromLoved(String trackKey) => _removeEntry( trackKey, contains: (key) => state.containsLovedKey(key), select: (state) => state.loved, keyOf: (entry) => entry.key, withList: (list) => state.copyWith(loved: list), dbDelete: _db.deleteLovedEntry, ); Future createPlaylist(String name) async { await _ensureLoaded(); final now = DateTime.now(); final id = 'pl_${now.microsecondsSinceEpoch}'; final trimmedName = name.trim(); final playlist = UserPlaylistCollection( id: id, name: trimmedName, createdAt: now, updatedAt: now, tracks: const [], ); await _db.upsertPlaylist( id: id, name: trimmedName, coverImagePath: null, createdAt: now.toIso8601String(), updatedAt: now.toIso8601String(), ); state = state.copyWith(playlists: [playlist, ...state.playlists]); _invalidatePlaylistPickerSummaries(); return id; } Future renamePlaylist(String playlistId, String newName) async { await _ensureLoaded(); final trimmed = newName.trim(); if (trimmed.isEmpty) return; final playlist = state.playlistById(playlistId); if (playlist == null || playlist.name == trimmed) return; final now = DateTime.now(); await _db.renamePlaylist( playlistId: playlistId, name: trimmed, updatedAt: now.toIso8601String(), ); _replacePlaylistById(playlistId, (playlist) { return playlist.copyWith(name: trimmed, updatedAt: now); }); _invalidatePlaylistPickerSummaries(); } Future deletePlaylist(String playlistId) async { await _ensureLoaded(); final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId); if (playlistIndex < 0) return; await _db.deletePlaylist(playlistId); final updatedPlaylists = [...state.playlists]..removeAt(playlistIndex); state = state.copyWith(playlists: updatedPlaylists); _invalidatePlaylistPickerSummaries(); } Future addTrackToPlaylist(String playlistId, Track track) async { await _ensureLoaded(); var playlist = state.playlistById(playlistId); if (playlist == null) return false; final key = trackCollectionKey(track); if (playlist.containsTrackKey(key)) return false; await ensurePlaylistLoaded(playlistId); playlist = state.playlistById(playlistId); if (playlist == null) return false; final now = DateTime.now(); final entry = CollectionTrackEntry(key: key, track: track, addedAt: now); await _db.upsertPlaylistTrack( playlistId: playlistId, trackKey: key, trackJson: jsonEncode(track.toJson()), addedAt: entry.addedAt.toIso8601String(), playlistUpdatedAt: now.toIso8601String(), ); final changed = _replacePlaylistById(playlistId, (playlist) { if (playlist.containsTrackKey(key)) return playlist; return playlist.copyWith( tracks: [...playlist.tracks, entry], updatedAt: now, ); }); if (!changed) return false; _invalidatePlaylistPickerSummaries(); return true; } Future addTracksToPlaylist( String playlistId, Iterable tracks, ) async { await _ensureLoaded(); await ensurePlaylistLoaded(playlistId); final playlist = state.playlistById(playlistId); if (playlist == null) { return const PlaylistAddBatchResult( addedCount: 0, alreadyInPlaylistCount: 0, ); } final now = DateTime.now(); final knownKeys = {...playlist._trackKeys}; final entriesToAdd = []; var alreadyInPlaylistCount = 0; for (final track in tracks) { final key = trackCollectionKey(track); if (!knownKeys.add(key)) { alreadyInPlaylistCount++; continue; } entriesToAdd.add( CollectionTrackEntry(key: key, track: track, addedAt: now), ); } if (entriesToAdd.isEmpty) { return PlaylistAddBatchResult( addedCount: 0, alreadyInPlaylistCount: alreadyInPlaylistCount, ); } await _db.upsertPlaylistTracksBatch( playlistId: playlistId, playlistUpdatedAt: now.toIso8601String(), tracks: entriesToAdd .map( (entry) => { 'track_key': entry.key, 'track_json': jsonEncode(entry.track.toJson()), 'added_at': entry.addedAt.toIso8601String(), }, ) .toList(growable: false), ); final changed = _replacePlaylistById(playlistId, (current) { return current.copyWith( // Append in playlist order, matching the ASC snapshot ordering. tracks: [...current.tracks, ...entriesToAdd], updatedAt: now, ); }); if (!changed) { return PlaylistAddBatchResult( addedCount: 0, alreadyInPlaylistCount: alreadyInPlaylistCount, ); } _invalidatePlaylistPickerSummaries(); return PlaylistAddBatchResult( addedCount: entriesToAdd.length, alreadyInPlaylistCount: alreadyInPlaylistCount, ); } Future removeTrackFromPlaylist( String playlistId, String trackKey, ) async { await _ensureLoaded(); var playlist = state.playlistById(playlistId); if (playlist == null || !playlist.containsTrackKey(trackKey)) return; await ensurePlaylistLoaded(playlistId); playlist = state.playlistById(playlistId); if (playlist == null) return; final now = DateTime.now(); await _db.deletePlaylistTrack( playlistId: playlistId, trackKey: trackKey, playlistUpdatedAt: now.toIso8601String(), ); _replacePlaylistById(playlistId, (playlist) { final nextTracks = playlist.tracks .where((entry) => entry.key != trackKey) .toList(growable: false); if (nextTracks.length == playlist.tracks.length) return playlist; return playlist.copyWith(tracks: nextTracks, updatedAt: now); }); _invalidatePlaylistPickerSummaries(); } Future _playlistCoversDir() async { final appDir = await getApplicationSupportDirectory(); final dir = Directory(p.join(appDir.path, 'playlist_covers')); if (!await dir.exists()) { await dir.create(recursive: true); } return dir; } Future setPlaylistCover( String playlistId, String sourceFilePath, ) async { await _ensureLoaded(); final playlist = state.playlistById(playlistId); if (playlist == null) return; final previousCoverPath = playlist.coverImagePath; final destPath = await _normalizePlaylistCoverFile( playlistId, sourceFilePath, ); final now = DateTime.now(); await _db.updatePlaylistCover( playlistId: playlistId, coverImagePath: destPath, updatedAt: now.toIso8601String(), ); _replacePlaylistById(playlistId, (playlist) { if (playlist.coverImagePath == destPath) return playlist; return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now); }); _invalidatePlaylistPickerSummaries(); if (previousCoverPath != null && previousCoverPath != destPath) { try { final previous = File(previousCoverPath); if (await previous.exists()) await previous.delete(); } catch (_) {} } } Future _normalizePlaylistCoverFile( String playlistId, String sourceFilePath, ) async { final coversDir = await _playlistCoversDir(); final destPath = p.join(coversDir.path, '$playlistId.jpg'); final tempPath = p.join( coversDir.path, '.$playlistId.${DateTime.now().microsecondsSinceEpoch}.jpg', ); try { final dimensions = await FFmpegService.probeImageDimensions( sourceFilePath, ); final longestEdge = dimensions == null ? _playlistCoverMaxDimension : (dimensions.width > dimensions.height ? dimensions.width : dimensions.height); var targetDimension = longestEdge .clamp(64, _playlistCoverMaxDimension) .toInt(); while (true) { final resized = await FFmpegService.resizeCoverArt( inputPath: sourceFilePath, outputPath: tempPath, maxDimension: targetDimension, ); if (!resized) { throw StateError('Unable to normalize playlist cover'); } if (await File(tempPath).length() <= _playlistCoverMaxStoredBytes) { break; } if (targetDimension <= 64) { throw StateError('Normalized playlist cover exceeds the size limit'); } targetDimension = (targetDimension * 3 ~/ 4) .clamp(64, targetDimension) .toInt(); } final destination = File(destPath); if (await destination.exists()) await destination.delete(); await File(tempPath).rename(destPath); return destPath; } finally { try { final temp = File(tempPath); if (await temp.exists()) await temp.delete(); } catch (_) {} } } Future _playlistCoverNeedsNormalization(String path) async { final file = File(path); if (!await file.exists()) return false; if (p.extension(path).toLowerCase() != '.jpg' || await file.length() > _playlistCoverMaxStoredBytes) { return true; } final dimensions = await FFmpegService.probeImageDimensions(path); return dimensions == null || dimensions.width > _playlistCoverMaxDimension || dimensions.height > _playlistCoverMaxDimension; } Future removePlaylistCover(String playlistId) async { await _ensureLoaded(); final playlist = state.playlistById(playlistId); if (playlist == null || playlist.coverImagePath == null) return; final path = playlist.coverImagePath; if (path != null) { final file = File(path); if (await file.exists()) { await file.delete(); } } final now = DateTime.now(); await _db.updatePlaylistCover( playlistId: playlistId, coverImagePath: null, updatedAt: now.toIso8601String(), ); _replacePlaylistById(playlistId, (playlist) { if (playlist.coverImagePath == null) return playlist; return playlist.copyWith(coverImagePath: () => null, updatedAt: now); }); _invalidatePlaylistPickerSummaries(); } /// Returns the full collections snapshot (wishlist, loved, playlists, /// favorite artists) for a backup, ensuring data is loaded first. Future> exportCollections() async { await _ensureLoaded(); await ensurePlaylistsLoaded(state.playlists.map((playlist) => playlist.id)); return state.toJson(); } /// Exports custom playlist cover images as base64, keyed by playlist id. /// Each value contains the original file extension and the encoded bytes so a /// restore on another device can recreate the cover files. Future>> exportPlaylistCovers() async { await _ensureLoaded(); final covers = >{}; final playlists = List.of(state.playlists); for (final playlist in playlists) { var path = playlist.coverImagePath; if (path == null || path.isEmpty) continue; try { if (await _playlistCoverNeedsNormalization(path)) { await setPlaylistCover(playlist.id, path); path = state.playlistById(playlist.id)?.coverImagePath ?? path; } final file = File(path); if (!await file.exists()) continue; final bytes = await file.readAsBytes(); if (bytes.isEmpty) continue; covers[playlist.id] = { 'ext': p.extension(path).toLowerCase(), 'data': base64Encode(bytes), }; } catch (_) { // Skip unreadable cover; the rest of the backup still succeeds. } } return covers; } /// Returns cover file references for the streaming ZIP backup format. The /// backup writer reads each file directly instead of materializing every /// image as base64 in memory. Future>> exportPlaylistCoverFiles() async { await _ensureLoaded(); final covers = >{}; final playlists = List.of(state.playlists); for (final playlist in playlists) { var path = playlist.coverImagePath; if (path == null || path.isEmpty) continue; try { if (await _playlistCoverNeedsNormalization(path)) { await setPlaylistCover(playlist.id, path); path = state.playlistById(playlist.id)?.coverImagePath ?? path; } final file = File(path); if (!await file.exists() || await file.length() == 0) continue; covers[playlist.id] = { 'ext': p.extension(path).toLowerCase(), 'path': path, }; } catch (_) { // Skip unreadable cover; the rest of the backup still succeeds. } } return covers; } /// Replaces all collections (wishlist, loved, playlists, favorite artists) /// with the contents of a backup. [collectionsJson] uses the /// [LibraryCollectionsState.toJson] shape; [coverImages] is the map produced /// by [exportPlaylistCovers]. Cover images are rewritten into this device's /// covers directory and their paths fixed up before persisting. Future restoreFromBackup( Map collectionsJson, { Map? coverImages, }) async { final normalized = Map.from(collectionsJson); final coversDir = await _playlistCoversDir(); final playlistsRaw = normalized['playlists']; if (playlistsRaw is List) { final rewritten = >[]; for (final entry in playlistsRaw.whereType>()) { final playlist = Map.from(entry); final id = playlist['id'] as String?; String? newCoverPath; final coverEntry = (id != null && coverImages != null) ? coverImages[id] : null; if (id != null && coverEntry is Map) { final data = coverEntry['data'] as String?; final restoredPath = coverEntry['path'] as String?; final ext = (coverEntry['ext'] as String?) ?? '.jpg'; if ((data != null && data.isNotEmpty) || (restoredPath != null && restoredPath.isNotEmpty)) { try { final sourcePath = p.join( coversDir.path, '.$id.restore${ext.startsWith('.') ? ext : '.$ext'}', ); final source = File(sourcePath); if (restoredPath != null && restoredPath.isNotEmpty) { await File(restoredPath).copy(source.path); } else { await source.writeAsBytes(base64Decode(data!), flush: true); } try { newCoverPath = await _normalizePlaylistCoverFile( id, sourcePath, ); } finally { try { if (await source.exists()) await source.delete(); } catch (_) {} } } catch (_) { newCoverPath = null; } } } // Always replace the backup's device-specific path: either with the // freshly written local cover, or drop it so a stale path is not kept. if (newCoverPath != null) { playlist['coverImagePath'] = newCoverPath; } else { playlist.remove('coverImagePath'); } rewritten.add(playlist); } normalized['playlists'] = rewritten; } await _db.replaceAllFromBackup(normalized); await _load(); _invalidatePlaylistPickerSummaries(); } } final libraryCollectionsProvider = NotifierProvider( LibraryCollectionsNotifier.new, ); final libraryPlaylistPickerSummariesProvider = FutureProvider.autoDispose .family, PlaylistPickerSummaryRequest>(( ref, request, ) async { final db = LibraryCollectionsDatabase.instance; await db.migrateFromSharedPreferences(); final rows = await db.loadPlaylistPickerSummaries(request.trackKeys); return rows .map( (row) => PlaylistPickerSummary( id: row.id, name: row.name, coverImagePath: row.coverImagePath, previewCover: row.previewCover, createdAt: row.createdAt, updatedAt: row.updatedAt, trackCount: row.trackCount, containsAllRequestedTracks: row.containsAllRequestedTracks, ), ) .toList(growable: false); });