feat(metadata): limit embedded cover resolution

This commit is contained in:
zarzet
2026-08-28 13:21:05 +07:00
parent 45d9bbd7b2
commit 887a8ede12
38 changed files with 639 additions and 54 deletions
+18
View File
@@ -8223,6 +8223,24 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Copy all metadata'**
String get metadataCopyAll;
/// Metadata setting that limits downloaded artwork resolution before it is embedded
///
/// In en, this message translates to:
/// **'Embedded Cover Size'**
String get optionsEmbeddedCoverSize;
/// Description shown in the embedded cover size picker
///
/// In en, this message translates to:
/// **'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.'**
String get optionsEmbeddedCoverSizeDescription;
/// Option that preserves the provider artwork at its original resolution
///
/// In en, this message translates to:
/// **'Original resolution'**
String get optionsEmbeddedCoverSizeOriginal;
}
class _AppLocalizationsDelegate
+10
View File
@@ -5047,4 +5047,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get metadataCopyAll => 'Copy all metadata';
@override
String get optionsEmbeddedCoverSize => 'Embedded Cover Size';
@override
String get optionsEmbeddedCoverSizeDescription =>
'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.';
@override
String get optionsEmbeddedCoverSizeOriginal => 'Original resolution';
}
+10
View File
@@ -5002,4 +5002,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get metadataCopyAll => 'Copy all metadata';
@override
String get optionsEmbeddedCoverSize => 'Embedded Cover Size';
@override
String get optionsEmbeddedCoverSizeDescription =>
'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.';
@override
String get optionsEmbeddedCoverSizeOriginal => 'Original resolution';
}
+10
View File
@@ -4997,6 +4997,16 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get metadataCopyAll => 'Copy all metadata';
@override
String get optionsEmbeddedCoverSize => 'Embedded Cover Size';
@override
String get optionsEmbeddedCoverSizeDescription =>
'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.';
@override
String get optionsEmbeddedCoverSizeOriginal => 'Original resolution';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+10
View File
@@ -5117,4 +5117,14 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get metadataCopyAll => 'Copy all metadata';
@override
String get optionsEmbeddedCoverSize => 'Embedded Cover Size';
@override
String get optionsEmbeddedCoverSizeDescription =>
'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.';
@override
String get optionsEmbeddedCoverSizeOriginal => 'Original resolution';
}
+10
View File
@@ -5000,4 +5000,14 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get metadataCopyAll => 'Salin semua metadata';
@override
String get optionsEmbeddedCoverSize => 'Ukuran Cover Tertanam';
@override
String get optionsEmbeddedCoverSizeDescription =>
'Perkecil cover yang diunduh dari internet sebelum ditanamkan. Gambar yang sudah berada dalam batas tidak akan diubah.';
@override
String get optionsEmbeddedCoverSizeOriginal => 'Resolusi asli';
}
+10
View File
@@ -4990,4 +4990,14 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get metadataCopyAll => 'Copy all metadata';
@override
String get optionsEmbeddedCoverSize => 'Embedded Cover Size';
@override
String get optionsEmbeddedCoverSizeDescription =>
'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.';
@override
String get optionsEmbeddedCoverSizeOriginal => 'Original resolution';
}
+10
View File
@@ -4871,4 +4871,14 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get metadataCopyAll => 'Copy all metadata';
@override
String get optionsEmbeddedCoverSize => 'Embedded Cover Size';
@override
String get optionsEmbeddedCoverSizeDescription =>
'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.';
@override
String get optionsEmbeddedCoverSizeOriginal => 'Original resolution';
}
+10
View File
@@ -4996,6 +4996,16 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get metadataCopyAll => 'Copy all metadata';
@override
String get optionsEmbeddedCoverSize => 'Embedded Cover Size';
@override
String get optionsEmbeddedCoverSizeDescription =>
'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.';
@override
String get optionsEmbeddedCoverSizeOriginal => 'Original resolution';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
+10
View File
@@ -5033,4 +5033,14 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get metadataCopyAll => 'Copy all metadata';
@override
String get optionsEmbeddedCoverSize => 'Embedded Cover Size';
@override
String get optionsEmbeddedCoverSizeDescription =>
'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.';
@override
String get optionsEmbeddedCoverSizeOriginal => 'Original resolution';
}
+10
View File
@@ -5032,4 +5032,14 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get metadataCopyAll => 'Copy all metadata';
@override
String get optionsEmbeddedCoverSize => 'Embedded Cover Size';
@override
String get optionsEmbeddedCoverSizeDescription =>
'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.';
@override
String get optionsEmbeddedCoverSizeOriginal => 'Original resolution';
}
+10
View File
@@ -5050,4 +5050,14 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get metadataCopyAll => 'Copy all metadata';
@override
String get optionsEmbeddedCoverSize => 'Embedded Cover Size';
@override
String get optionsEmbeddedCoverSizeDescription =>
'Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.';
@override
String get optionsEmbeddedCoverSizeOriginal => 'Original resolution';
}
+12
View File
@@ -6475,5 +6475,17 @@
"metadataCopyAll": "Copy all metadata",
"@metadataCopyAll": {
"description": "Metadata menu action that copies every visible key and value"
},
"optionsEmbeddedCoverSize": "Embedded Cover Size",
"@optionsEmbeddedCoverSize": {
"description": "Metadata setting that limits downloaded artwork resolution before it is embedded"
},
"optionsEmbeddedCoverSizeDescription": "Downscale remotely downloaded cover art before embedding it. Images already within the limit are kept unchanged.",
"@optionsEmbeddedCoverSizeDescription": {
"description": "Description shown in the embedded cover size picker"
},
"optionsEmbeddedCoverSizeOriginal": "Original resolution",
"@optionsEmbeddedCoverSizeOriginal": {
"description": "Option that preserves the provider artwork at its original resolution"
}
}
+4 -1
View File
@@ -6153,5 +6153,8 @@
"trackOptionCopyTrackAndArtist": "Salin judul dan artis",
"metadataCopyValue": "Salin nilai",
"metadataCopyField": "Salin field dan nilai",
"metadataCopyAll": "Salin semua metadata"
"metadataCopyAll": "Salin semua metadata",
"optionsEmbeddedCoverSize": "Ukuran Cover Tertanam",
"optionsEmbeddedCoverSizeDescription": "Perkecil cover yang diunduh dari internet sebelum ditanamkan. Gambar yang sudah berada dalam batas tidak akan diubah.",
"optionsEmbeddedCoverSizeOriginal": "Resolusi asli"
}
+8
View File
@@ -21,6 +21,10 @@ class AppSettings {
final String downloadTreeUri; // SAF persistable tree URI
final bool autoFallback;
final bool embedMetadata;
/// Maximum width or height of remotely fetched artwork before embedding.
/// Zero preserves the provider's original image.
final int embeddedCoverMaxDimension;
final String
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
final bool embedLyrics;
@@ -131,6 +135,7 @@ class AppSettings {
this.downloadTreeUri = '',
this.autoFallback = true,
this.embedMetadata = true,
this.embeddedCoverMaxDimension = 0,
this.artistTagMode = artistTagModeJoined,
this.embedLyrics = true,
this.embedReplayGain = false,
@@ -205,6 +210,7 @@ class AppSettings {
String? downloadTreeUri,
bool? autoFallback,
bool? embedMetadata,
int? embeddedCoverMaxDimension,
String? artistTagMode,
bool? embedLyrics,
bool? embedReplayGain,
@@ -282,6 +288,8 @@ class AppSettings {
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
autoFallback: autoFallback ?? this.autoFallback,
embedMetadata: embedMetadata ?? this.embedMetadata,
embeddedCoverMaxDimension:
embeddedCoverMaxDimension ?? this.embeddedCoverMaxDimension,
artistTagMode: artistTagMode ?? this.artistTagMode,
embedLyrics: embedLyrics ?? this.embedLyrics,
embedReplayGain: embedReplayGain ?? this.embedReplayGain,
+3
View File
@@ -16,6 +16,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
embedMetadata: json['embedMetadata'] as bool? ?? true,
embeddedCoverMaxDimension:
(json['embeddedCoverMaxDimension'] as num?)?.toInt() ?? 0,
artistTagMode: json['artistTagMode'] as String? ?? artistTagModeJoined,
embedLyrics: json['embedLyrics'] as bool? ?? true,
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
@@ -111,6 +113,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'downloadTreeUri': instance.downloadTreeUri,
'autoFallback': instance.autoFallback,
'embedMetadata': instance.embedMetadata,
'embeddedCoverMaxDimension': instance.embeddedCoverMaxDimension,
'artistTagMode': instance.artistTagMode,
'embedLyrics': instance.embedLyrics,
'embedReplayGain': instance.embedReplayGain,
@@ -849,6 +849,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumName: track.albumName,
albumArtist: resolvedAlbumArtist ?? '',
coverUrl: settings.embedMetadata ? (track.coverUrl ?? '') : '',
coverMaxDimension: settings.embeddedCoverMaxDimension,
outputDir: outputDir,
filenameFormat: filenameFormat,
quality: quality,
@@ -657,7 +657,10 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
// Started here, awaited only after the lyrics fetch below so the two
// network round trips overlap. Errors are handled inside the fetch
// (it resolves to null), never as an unhandled rejection.
coverFuture = _sharedEmbedCover(coverUrl);
coverFuture = _sharedEmbedCover(
coverUrl,
settings.embeddedCoverMaxDimension,
);
}
String? lrcContent;
@@ -1019,24 +1022,25 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
static const _embedCoverCacheMax = 8;
/// One cover fetch per URL, shared by every track in the batch.
Future<String?> _sharedEmbedCover(String coverUrl) {
final existing = _embedCoverCache.remove(coverUrl);
Future<String?> _sharedEmbedCover(String coverUrl, int maxDimension) {
final cacheKey = '$maxDimension\u0000$coverUrl';
final existing = _embedCoverCache.remove(cacheKey);
if (existing != null) {
_embedCoverCache[coverUrl] = existing; // LRU touch
_embedCoverCache[cacheKey] = existing; // LRU touch
return existing;
}
final fetch = _downloadEmbedCover(coverUrl).then((path) {
if (path == null) _embedCoverCache.remove(coverUrl); // allow retry
final fetch = _downloadEmbedCover(coverUrl, maxDimension).then((path) {
if (path == null) _embedCoverCache.remove(cacheKey); // allow retry
return path;
});
_embedCoverCache[coverUrl] = fetch;
_embedCoverCache[cacheKey] = fetch;
while (_embedCoverCache.length > _embedCoverCacheMax) {
_evictEmbedCover(_embedCoverCache.keys.first);
}
return fetch;
}
Future<String?> _downloadEmbedCover(String coverUrl) async {
Future<String?> _downloadEmbedCover(String coverUrl, int maxDimension) async {
try {
final tempDir = await getTemporaryDirectory();
final uniqueId =
@@ -1047,6 +1051,7 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
final result = await PlatformBridge.downloadCoverToFile(
coverUrl,
coverPath,
maxDimension: maxDimension,
);
if (result['error'] != null) {
_log.w('Failed to download cover: ${result['error']}');
+26
View File
@@ -108,6 +108,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
'external_first',
'in_app_first',
};
static const Set<int> _embeddedCoverMaxDimensionValues = {
0,
500,
1000,
1500,
2000,
};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -169,6 +176,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
autoConvertBitrate: normalizeAutoConvertBitrate(
loaded.autoConvertBitrate,
),
embeddedCoverMaxDimension: _normalizeEmbeddedCoverMaxDimension(
loaded.embeddedCoverMaxDimension,
),
defaultService: loaded.defaultService,
searchProvider: loaded.searchProvider,
extensionVerificationBrowserMode:
@@ -327,6 +337,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
downloadDirectoryBookmark: current.downloadDirectoryBookmark,
storageMode: current.storageMode,
downloadTreeUri: current.downloadTreeUri,
embeddedCoverMaxDimension: _normalizeEmbeddedCoverMaxDimension(
restored.embeddedCoverMaxDimension,
),
);
await _saveSettings();
@@ -401,6 +414,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
return 'in_app_first';
}
int _normalizeEmbeddedCoverMaxDimension(int value) {
return _embeddedCoverMaxDimensionValues.contains(value) ? value : 0;
}
String? _sanitizeRetiredBuiltInProviderId(String? providerId) {
final normalized = providerId?.trim().toLowerCase();
if (normalized == null || normalized.isEmpty) return providerId;
@@ -528,6 +545,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setEmbeddedCoverMaxDimension(int maxDimension) {
state = state.copyWith(
embeddedCoverMaxDimension: _normalizeEmbeddedCoverMaxDimension(
maxDimension,
),
);
_saveSettings();
}
void setArtistTagMode(String mode) {
if (mode == artistTagModeJoined || mode == artistTagModeSplitVorbis) {
state = state.copyWith(artistTagMode: mode);
@@ -52,6 +52,19 @@ class MetadataSettingsPage extends ConsumerWidget {
settings.artistTagMode,
),
),
SettingsItem(
icon: Icons.photo_size_select_large_outlined,
title: context.l10n.optionsEmbeddedCoverSize,
subtitle: _getEmbeddedCoverSizeLabel(
context,
settings.embeddedCoverMaxDimension,
),
onTap: () => _showEmbeddedCoverSizePicker(
context,
ref,
settings.embeddedCoverMaxDimension,
),
),
SettingsSwitchItem(
icon: Icons.graphic_eq,
title: context.l10n.optionsReplayGain,
@@ -146,6 +159,77 @@ class MetadataSettingsPage extends ConsumerWidget {
}
}
String _getEmbeddedCoverSizeLabel(BuildContext context, int maxDimension) {
if (maxDimension <= 0) {
return context.l10n.optionsEmbeddedCoverSizeOriginal;
}
return '$maxDimension × $maxDimension px';
}
void _showEmbeddedCoverSizePicker(
BuildContext context,
WidgetRef ref,
int currentMaxDimension,
) {
const options = [0, 500, 1000, 1500, 2000];
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.optionsEmbeddedCoverSize,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
context.l10n.optionsEmbeddedCoverSizeDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
for (final maxDimension in options)
ListTile(
leading: Icon(
maxDimension == 0
? Icons.image_outlined
: Icons.compress_outlined,
),
title: Text(
_getEmbeddedCoverSizeLabel(context, maxDimension),
),
trailing: currentMaxDimension == maxDimension
? const Icon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setEmbeddedCoverMaxDimension(maxDimension);
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
),
);
}
void _showArtistTagModePicker(
BuildContext context,
WidgetRef ref,
@@ -242,6 +242,12 @@ class SettingsSearchCatalog {
title: l10n.optionsArtistTagMode,
keywords: const ['artist separator', 'multiple artists'],
),
SettingsSearchEntry(
icon: Icons.photo_size_select_large_outlined,
title: l10n.optionsEmbeddedCoverSize,
subtitle: l10n.optionsEmbeddedCoverSizeDescription,
keywords: const ['cover size', 'artwork resolution', 'resize image'],
),
SettingsSearchEntry(
icon: Icons.graphic_eq,
title: l10n.optionsReplayGain,
+1
View File
@@ -992,6 +992,7 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
final request = <String, dynamic>{
'file_path': cleanFilePath,
'cover_url': _coverUrl ?? '',
'cover_max_dimension': settings.embeddedCoverMaxDimension,
'embed_lyrics': settings.embedLyrics,
'lyrics_mode': settings.lyricsMode,
'artist_tag_mode': artistTagMode,
@@ -81,6 +81,7 @@ Map<String, dynamic> buildBatchReEnrichRequest({
final request = <String, dynamic>{
'file_path': item.filePath,
'cover_url': '',
'cover_max_dimension': settings.embeddedCoverMaxDimension,
'embed_lyrics': settings.embedLyrics,
'lyrics_mode': settings.lyricsMode,
'artist_tag_mode': settings.artistTagMode,
@@ -12,6 +12,7 @@ class DownloadRequestPayload {
final String albumName;
final String albumArtist;
final String coverUrl;
final int coverMaxDimension;
final String outputDir;
final String filenameFormat;
final String quality;
@@ -74,6 +75,7 @@ class DownloadRequestPayload {
required this.albumName,
this.albumArtist = '',
this.coverUrl = '',
this.coverMaxDimension = 0,
required this.outputDir,
required this.filenameFormat,
this.quality = 'LOSSLESS',
@@ -138,6 +140,7 @@ class DownloadRequestPayload {
'album_name': albumName,
'album_artist': albumArtist,
'cover_url': coverUrl,
'cover_max_dimension': coverMaxDimension,
'output_dir': outputDir,
'filename_format': filenameFormat,
'quality': quality,
@@ -206,6 +209,7 @@ class DownloadRequestPayload {
albumName: albumName,
albumArtist: albumArtist,
coverUrl: coverUrl,
coverMaxDimension: coverMaxDimension,
outputDir: outputDir,
filenameFormat: filenameFormat,
quality: quality,
+4 -2
View File
@@ -956,11 +956,13 @@ class PlatformBridge {
static Future<Map<String, dynamic>> downloadCoverToFile(
String coverUrl,
String outputPath,
) {
String outputPath, {
int maxDimension = 0,
}) {
return _invokeMap('downloadCoverToFile', {
'cover_url': coverUrl,
'output_path': outputPath,
'max_dimension': maxDimension,
});
}