From e750c195ca0871fd087c6abeef9182ee3bfddcd1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 11 Aug 2026 16:01:39 +0700 Subject: [PATCH] feat(search): save max-quality cover on long press #511 --- lib/screens/home_tab.dart | 2 + lib/screens/home_tab_search_results.dart | 49 +++++++ lib/screens/home_tab_widgets.dart | 99 +++++++++----- lib/services/cover_download_service.dart | 158 +++++++++++++++++++++++ test/cover_download_service_test.dart | 64 +++++++++ 5 files changed, 339 insertions(+), 33 deletions(-) create mode 100644 lib/services/cover_download_service.dart create mode 100644 test/cover_download_service_test.dart diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index be04a900..1ebd0b48 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -22,6 +22,7 @@ import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/screens/home_search_logic.dart'; import 'package:spotiflac_android/services/csv_import_service.dart'; +import 'package:spotiflac_android/services/cover_download_service.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; @@ -75,6 +76,7 @@ class _HomeTabState extends ConsumerState bool _embeddedCoverRefreshScheduled = false; List? _thumbnailSizesExtensionsCache; bool _isCsvImporting = false; + final Set _activeCoverDownloads = {}; void _setCsvImporting(bool value) { if (_isCsvImporting == value) return; diff --git a/lib/screens/home_tab_search_results.dart b/lib/screens/home_tab_search_results.dart index 3c9d583f..7ff83d86 100644 --- a/lib/screens/home_tab_search_results.dart +++ b/lib/screens/home_tab_search_results.dart @@ -2,6 +2,51 @@ part of 'home_tab.dart'; extension _HomeTabSearchResultsUI on _HomeTabState { + Future _saveSearchResultCover(Track item) async { + final coverUrl = item.coverUrl?.trim() ?? ''; + if (coverUrl.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.trackCoverNoSource))); + return; + } + if (!_activeCoverDownloads.add(coverUrl)) return; + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(context.l10n.updateDownloading))); + + final baseName = item.isCollection || item.artistName.trim().isEmpty + ? item.name + : '${item.artistName} - ${item.name}'; + try { + final saved = await CoverDownloadService.saveRemoteCover( + coverUrl: coverUrl, + baseName: baseName, + settings: ref.read(settingsProvider), + ); + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(context.l10n.trackCoverSaved(saved.fileName))), + ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed(context.friendlyError(error)), + ), + ), + ); + } finally { + _activeCoverDownloads.remove(coverUrl); + } + } + Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final l10n = context.l10n; final isRateLimit = @@ -312,6 +357,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { item: artistItems[index], showDivider: showDivider, onTap: () => _navigateToExtensionArtist(artistItems[index]), + onSaveCover: () => _saveSearchResultCover(artistItems[index]), ), ), ); @@ -330,6 +376,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { item: albumItems[index], showDivider: showDivider, onTap: () => _navigateToExtensionAlbum(albumItems[index]), + onSaveCover: () => _saveSearchResultCover(albumItems[index]), ), ), ); @@ -348,6 +395,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { item: playlistItems[index], showDivider: showDivider, onTap: () => _navigateToExtensionPlaylist(playlistItems[index]), + onSaveCover: () => _saveSearchResultCover(playlistItems[index]), ), ), ); @@ -386,6 +434,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { isInHistory: existingHistoryKeys.contains( historyLookups[index].lookupKey, ), + onSaveCover: () => _saveSearchResultCover(sortedTracks[index]), ), ), ); diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 0fc95716..e2bdfbf5 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -196,6 +196,7 @@ class _TrackItemWithStatus extends ConsumerWidget { final String? searchExtensionId; final bool showLocalLibraryIndicator; final Map thumbnailSizesByExtensionId; + final VoidCallback onSaveCover; /// Resolved by the result page via one batch lookup instead of a per-row /// exists query (which in SAF mode also costs a bridge call per row). @@ -211,6 +212,7 @@ class _TrackItemWithStatus extends ConsumerWidget { required this.showLocalLibraryIndicator, required this.thumbnailSizesByExtensionId, required this.isInHistory, + required this.onSaveCover, }); @override @@ -248,6 +250,7 @@ class _TrackItemWithStatus extends ConsumerWidget { } final isQueued = queueItem != null; + final hasCover = track.coverUrl?.isNotEmpty == true; return Column( mainAxisSize: MainAxisSize.min, @@ -272,24 +275,36 @@ class _TrackItemWithStatus extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(12, 10, 6, 10), child: Row( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: track.coverUrl != null - ? CachedCoverImage( - imageUrl: track.coverUrl!, - width: thumbWidth, - height: thumbHeight, - fit: BoxFit.cover, - ) - : Container( - width: thumbWidth, - height: thumbHeight, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - ), - ), + Semantics( + label: + '${context.l10n.dialogDownload} ' + '${context.l10n.editMetadataFieldCover}: ${track.name}', + button: hasCover, + onLongPress: hasCover ? onSaveCover : null, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onLongPress: hasCover ? onSaveCover : null, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: hasCover + ? CachedCoverImage( + imageUrl: track.coverUrl!, + width: thumbWidth, + height: thumbHeight, + fit: BoxFit.cover, + ) + : Container( + width: thumbWidth, + height: thumbHeight, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), ), const SizedBox(width: 12), Expanded( @@ -417,12 +432,14 @@ class _CollectionItemWidget extends StatelessWidget { final Track item; final bool showDivider; final VoidCallback onTap; + final VoidCallback onSaveCover; const _CollectionItemWidget({ super.key, required this.item, required this.showDivider, required this.onTap, + required this.onSaveCover, }); @override @@ -435,21 +452,37 @@ class _CollectionItemWidget extends StatelessWidget { if (isPlaylist) placeholderIcon = Icons.playlist_play; if (isArtist) placeholderIcon = Icons.person; - final cover = ClipRRect( - borderRadius: BorderRadius.circular(isArtist ? 28 : 10), - child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedCoverImage( - imageUrl: item.coverUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon(placeholderIcon, color: colorScheme.onSurfaceVariant), - ), + final hasCover = item.coverUrl != null && item.coverUrl!.isNotEmpty; + final cover = Semantics( + label: + '${context.l10n.dialogDownload} ' + '${context.l10n.editMetadataFieldCover}: ${item.name}', + button: hasCover, + onLongPress: hasCover ? onSaveCover : null, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onLongPress: hasCover ? onSaveCover : null, + child: ClipRRect( + borderRadius: BorderRadius.circular(isArtist ? 28 : 10), + child: hasCover + ? CachedCoverImage( + imageUrl: item.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + placeholderIcon, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), ); return Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/services/cover_download_service.dart b/lib/services/cover_download_service.dart new file mode 100644 index 00000000..029ad8b3 --- /dev/null +++ b/lib/services/cover_download_service.dart @@ -0,0 +1,158 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +class SavedCoverResult { + final String fileName; + final String location; + + const SavedCoverResult({required this.fileName, required this.location}); +} + +class CoverDownloadService { + const CoverDownloadService._(); + + static Future saveRemoteCover({ + required String coverUrl, + required String baseName, + required AppSettings settings, + }) async { + final normalizedUrl = coverUrl.trim(); + if (normalizedUrl.isEmpty) { + throw const FormatException('No cover art source available'); + } + + final safeBaseName = await PlatformBridge.sanitizeFilename(baseName.trim()); + final resolvedBaseName = safeBaseName.trim().isEmpty + ? 'cover' + : safeBaseName.trim(); + final tempDir = await Directory.systemTemp.createTemp('save_cover_'); + final tempPath = p.join(tempDir.path, 'cover.image'); + var iosBookmarkActive = false; + + try { + final download = await PlatformBridge.downloadCoverToFile( + normalizedUrl, + tempPath, + maxQuality: true, + ); + final error = download['error']?.toString().trim() ?? ''; + if (error.isNotEmpty) throw StateError(error); + + final tempFile = File(tempPath); + if (!await tempFile.exists() || await tempFile.length() <= 0) { + throw const FileSystemException('Downloaded cover is empty'); + } + + final format = await detectCoverFileFormat(tempFile); + final requestedFileName = '${resolvedBaseName}_cover.${format.extension}'; + + if (Platform.isAndroid && settings.storageMode == 'saf') { + final treeUri = settings.downloadTreeUri.trim(); + if (treeUri.isEmpty) { + throw const FileSystemException('No storage access'); + } + final result = await PlatformBridge.createUniqueSafFileFromPath( + treeUri: treeUri, + relativeDir: '', + fileName: requestedFileName, + mimeType: format.mimeType, + srcPath: tempPath, + ); + final uri = result['uri']?.toString().trim() ?? ''; + final fileName = result['file_name']?.toString().trim() ?? ''; + final writeError = result['error']?.toString().trim() ?? ''; + if (writeError.isNotEmpty) throw StateError(writeError); + if (uri.isEmpty || fileName.isEmpty) { + throw const FileSystemException('Failed to write cover to storage'); + } + return SavedCoverResult(fileName: fileName, location: uri); + } + + var outputDirectory = settings.downloadDirectory.trim(); + if (Platform.isIOS && settings.downloadDirectoryBookmark.isNotEmpty) { + final resolved = await PlatformBridge.startAccessingIosBookmark( + settings.downloadDirectoryBookmark, + ); + if (resolved == null || resolved.trim().isEmpty) { + throw const FileSystemException('No storage access'); + } + iosBookmarkActive = true; + outputDirectory = resolved.trim(); + } + if (outputDirectory.isEmpty) { + final documents = await getApplicationDocumentsDirectory(); + outputDirectory = p.join(documents.path, 'SpotiFLAC'); + } + + final directory = Directory(outputDirectory); + await directory.create(recursive: true); + final outputPath = await uniqueFilePath( + directory.path, + requestedFileName, + ); + await tempFile.copy(outputPath); + return SavedCoverResult( + fileName: p.basename(outputPath), + location: outputPath, + ); + } finally { + if (iosBookmarkActive) { + await PlatformBridge.stopAccessingIosBookmark(); + } + try { + if (await tempDir.exists()) await tempDir.delete(recursive: true); + } catch (_) {} + } + } + + static Future<({String extension, String mimeType})> detectCoverFileFormat( + File file, + ) async { + final reader = await file.open(); + try { + final bytes = await reader.read(12); + if (bytes.length >= 8 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4e && + bytes[3] == 0x47) { + return (extension: 'png', mimeType: 'image/png'); + } + if (bytes.length >= 12 && + bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46 && + bytes[8] == 0x57 && + bytes[9] == 0x45 && + bytes[10] == 0x42 && + bytes[11] == 0x50) { + return (extension: 'webp', mimeType: 'image/webp'); + } + return (extension: 'jpg', mimeType: 'image/jpeg'); + } finally { + await reader.close(); + } + } + + static Future uniqueFilePath( + String directory, + String fileName, + ) async { + var candidate = p.join(directory, fileName); + if (!await File(candidate).exists()) return candidate; + + final extension = p.extension(fileName); + final stem = p.basenameWithoutExtension(fileName); + var counter = 2; + while (await File(candidate).exists()) { + candidate = p.join(directory, '$stem ($counter)$extension'); + counter++; + } + return candidate; + } +} diff --git a/test/cover_download_service_test.dart b/test/cover_download_service_test.dart new file mode 100644 index 00000000..d221ab91 --- /dev/null +++ b/test/cover_download_service_test.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/cover_download_service.dart'; + +void main() { + test('detects common cover formats from file headers', () async { + final directory = await Directory.systemTemp.createTemp('cover_format_'); + addTearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + + final png = File( + '${directory.path}/png', + )..writeAsBytesSync(const [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + final webp = File('${directory.path}/webp') + ..writeAsBytesSync(const [ + 0x52, + 0x49, + 0x46, + 0x46, + 0, + 0, + 0, + 0, + 0x57, + 0x45, + 0x42, + 0x50, + ]); + final jpeg = File('${directory.path}/jpeg') + ..writeAsBytesSync(const [0xff, 0xd8, 0xff, 0xe0]); + + expect(await CoverDownloadService.detectCoverFileFormat(png), ( + extension: 'png', + mimeType: 'image/png', + )); + expect(await CoverDownloadService.detectCoverFileFormat(webp), ( + extension: 'webp', + mimeType: 'image/webp', + )); + expect(await CoverDownloadService.detectCoverFileFormat(jpeg), ( + extension: 'jpg', + mimeType: 'image/jpeg', + )); + }); + + test('builds a collision-free file path', () async { + final directory = await Directory.systemTemp.createTemp('cover_name_'); + addTearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + + File('${directory.path}/Album_cover.jpg').writeAsStringSync('one'); + File('${directory.path}/Album_cover (2).jpg').writeAsStringSync('two'); + + final result = await CoverDownloadService.uniqueFilePath( + directory.path, + 'Album_cover.jpg', + ); + + expect(result, endsWith('Album_cover (3).jpg')); + }); +}