From 857c2954d8d9bdb5f8705f20d7cd7aff20d94cff Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:57:12 +0700 Subject: [PATCH] refactor(collections): move playlist cover file I/O into PlaylistCoverStore LibraryCollectionsNotifier carried about 130 lines of File and Directory work for playlist covers. PlaylistCoverStore now owns the playlist_covers directory, the .jpg normalization with its temp file, the backup restore copy or base64 write, and the delete helpers. The directory is still resolved through getApplicationSupportDirectory on each call, at the same points as before. The notifier keeps setPlaylistCover, removePlaylistCover, exportPlaylistCoverFiles and restoreFromBackup with unchanged signatures, mutation ordering and error handling: a failed restore of one cover still drops that cover path, removePlaylistCover still propagates delete errors, and replacing a cover still deletes the old file quietly. --- .../library_collections_provider.dart | 167 ++++-------------- lib/services/playlist_cover_store.dart | 143 +++++++++++++++ 2 files changed, 177 insertions(+), 133 deletions(-) create mode 100644 lib/services/playlist_cover_store.dart 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; + } + } +}