feat(search): save max-quality cover on long press #511

This commit is contained in:
zarzet
2026-08-11 16:01:39 +07:00
parent f189687e08
commit e750c195ca
5 changed files with 339 additions and 33 deletions
+2
View File
@@ -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<HomeTab>
bool _embeddedCoverRefreshScheduled = false;
List<Extension>? _thumbnailSizesExtensionsCache;
bool _isCsvImporting = false;
final Set<String> _activeCoverDownloads = <String>{};
void _setCsvImporting(bool value) {
if (_isCsvImporting == value) return;
+49
View File
@@ -2,6 +2,51 @@
part of 'home_tab.dart';
extension _HomeTabSearchResultsUI on _HomeTabState {
Future<void> _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]),
),
),
);
+66 -33
View File
@@ -196,6 +196,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
final String? searchExtensionId;
final bool showLocalLibraryIndicator;
final Map<String, (double, double)> 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,
+158
View File
@@ -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<SavedCoverResult> 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<String> 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;
}
}
+64
View File
@@ -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'));
});
}