mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-26 21:02:28 +02:00
feat(search): save max-quality cover on long press #511
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user