diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index ab94d6a7..5f974d8c 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -1,23 +1,18 @@ import 'dart:convert'; -import 'dart:io'; import 'package:flutter/foundation.dart' show visibleForTesting; 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/library_collections.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'; import 'package:spotiflac_android/services/library_collections_hydration.dart'; import 'package:spotiflac_android/services/collection_track_batch.dart'; -import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/services/playlist_cover_store.dart'; import 'package:spotiflac_android/utils/logger.dart'; export 'package:spotiflac_android/models/library_collections.dart'; -const _playlistCoverMaxDimension = 1024; -const _playlistCoverMaxStoredBytes = 2 * 1024 * 1024; final _log = AppLogger('LibraryCollections'); class LibraryCollectionsNotifier extends Notifier { @@ -25,6 +20,7 @@ class LibraryCollectionsNotifier extends Notifier { : _db = database ?? LibraryCollectionsDatabase.instance; final LibraryCollectionsDatabase _db; + final PlaylistCoverStore _coverStore = const PlaylistCoverStore(); Future? _loadFuture; final Map> _playlistLoadFutures = {}; Future _mutationTail = Future.value(); @@ -650,107 +646,31 @@ class LibraryCollectionsNotifier extends Notifier { }); } - 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) => - _mutate(() async { - 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) { - await deleteFileQuietly(File(previousCoverPath)); - } - }); - - Future _normalizePlaylistCoverFile( + Future setPlaylistCover( 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 { - await deleteFileQuietly(File(tempPath)); - } - } + ) => _mutate(() async { + final playlist = state.playlistById(playlistId); + if (playlist == null) return; - 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 previousCoverPath = playlist.coverImagePath; + final destPath = await _coverStore.normalize(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) { + await _coverStore.deleteQuietly(previousCoverPath); } - final dimensions = await FFmpegService.probeImageDimensions(path); - return dimensions == null || - dimensions.width > _playlistCoverMaxDimension || - dimensions.height > _playlistCoverMaxDimension; - } + }); Future removePlaylistCover(String playlistId) => _mutate(() async { final playlist = state.playlistById(playlistId); @@ -758,10 +678,7 @@ class LibraryCollectionsNotifier extends Notifier { final path = playlist.coverImagePath; if (path != null) { - final file = File(path); - if (await file.exists()) { - await file.delete(); - } + await _coverStore.delete(path); } final now = DateTime.now(); @@ -794,12 +711,11 @@ class LibraryCollectionsNotifier extends Notifier { var path = playlist.coverImagePath; if (path == null || path.isEmpty) continue; try { - if (await _playlistCoverNeedsNormalization(path)) { + if (await _coverStore.needsNormalization(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; + if (!await _coverStore.hasContent(path)) continue; covers[playlist.id] = { 'ext': p.extension(path).toLowerCase(), 'path': path, @@ -822,7 +738,7 @@ class LibraryCollectionsNotifier extends Notifier { Map? coverImages, }) => _mutate(() async { final normalized = Map.from(collectionsJson); - final coversDir = await _playlistCoversDir(); + final coversDir = await _coverStore.coversDirectory(); final playlistsRaw = normalized['playlists']; if (playlistsRaw is List) { @@ -840,28 +756,13 @@ class LibraryCollectionsNotifier extends Notifier { 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 { - await deleteFileQuietly(source); - } - } catch (_) { - newCoverPath = null; - } + newCoverPath = await _coverStore.restoreFromBackup( + coversDir: coversDir, + id: id, + ext: ext, + restoredPath: restoredPath, + data: data, + ); } } // Always replace the backup's device-specific path: either with the diff --git a/lib/services/playlist_cover_store.dart b/lib/services/playlist_cover_store.dart new file mode 100644 index 00000000..3e4c25bb --- /dev/null +++ b/lib/services/playlist_cover_store.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; + +const _playlistCoverMaxDimension = 1024; +const _playlistCoverMaxStoredBytes = 2 * 1024 * 1024; + +/// File storage for user playlist covers under the app support +/// `playlist_covers` directory. Covers are stored as `.jpg`, +/// normalized to at most [_playlistCoverMaxDimension] pixels and +/// [_playlistCoverMaxStoredBytes] bytes. +class PlaylistCoverStore { + const PlaylistCoverStore({ + Future Function() supportDirectory = + getApplicationSupportDirectory, + }) : _supportDirectory = supportDirectory; + + final Future Function() _supportDirectory; + + /// Resolves the covers directory on every call and creates it when missing. + Future coversDirectory() async { + final appDir = await _supportDirectory(); + final dir = Directory(p.join(appDir.path, 'playlist_covers')); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + /// Writes a normalized JPEG for [playlistId] from [sourceFilePath] and + /// returns its path. Errors propagate; the temporary file is always removed. + Future normalize(String playlistId, String sourceFilePath) async { + final coversDir = await coversDirectory(); + 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 { + await deleteFileQuietly(File(tempPath)); + } + } + + Future needsNormalization(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; + } + + /// Deletes the cover at [path] when it exists. Errors propagate. + Future delete(String path) async { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } + + /// Deletes the cover at [path] when it exists and swallows any error. + Future deleteQuietly(String path) => deleteFileQuietly(File(path)); + + /// Whether [path] exists and is not empty. Errors propagate. + Future hasContent(String path) async { + final file = File(path); + return await file.exists() && await file.length() != 0; + } + + /// Restores a backup cover for [id] into [coversDir] from either an + /// extracted file at [restoredPath] or legacy base64 [data], then + /// normalizes it. Returns null when any step fails. + Future restoreFromBackup({ + required Directory coversDir, + required String id, + required String ext, + String? restoredPath, + String? data, + }) async { + 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 { + return await normalize(id, sourcePath); + } finally { + await deleteFileQuietly(source); + } + } catch (_) { + return null; + } + } +}