feat(metadata): add embedded cover resize options

This commit is contained in:
zarzet
2026-08-01 18:24:27 +07:00
parent ed60cafb90
commit d4b617f49a
17 changed files with 441 additions and 34 deletions
+18
View File
@@ -6372,6 +6372,24 @@ abstract class AppLocalizations {
/// **'The selected cover will replace the current embedded cover when you tap Save.'**
String get trackCoverReplaceNotice;
/// Label for selecting the embedded cover art resolution
///
/// In en, this message translates to:
/// **'Cover resolution'**
String get trackCoverResolution;
/// Explanation below the embedded cover art resolution selector
///
/// In en, this message translates to:
/// **'Sets the longest edge when saved. Enlarging does not add image detail.'**
String get trackCoverResolutionHint;
/// Error shown when resizing cover art before saving metadata fails
///
/// In en, this message translates to:
/// **'The cover image could not be resized. Please try another size or image.'**
String get trackCoverResizeFailed;
/// Generic action - stop
///
/// In en, this message translates to:
+11
View File
@@ -3895,6 +3895,17 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackCoverReplaceNotice =>
'Das ausgewählte Cover ersetzt das aktuell eingebettete Cover, wenn auf speichern gedrückt wird.';
@override
String get trackCoverResolution => 'Cover resolution';
@override
String get trackCoverResolutionHint =>
'Sets the longest edge when saved. Enlarging does not add image detail.';
@override
String get trackCoverResizeFailed =>
'The cover image could not be resized. Please try another size or image.';
@override
String get actionStop => 'Stop';
+11
View File
@@ -3849,6 +3849,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get trackCoverResolution => 'Cover resolution';
@override
String get trackCoverResolutionHint =>
'Sets the longest edge when saved. Enlarging does not add image detail.';
@override
String get trackCoverResizeFailed =>
'The cover image could not be resized. Please try another size or image.';
@override
String get actionStop => 'Stop';
+11
View File
@@ -3843,6 +3843,17 @@ class AppLocalizationsEs extends AppLocalizations {
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get trackCoverResolution => 'Cover resolution';
@override
String get trackCoverResolutionHint =>
'Sets the longest edge when saved. Enlarging does not add image detail.';
@override
String get trackCoverResizeFailed =>
'The cover image could not be resized. Please try another size or image.';
@override
String get actionStop => 'Stop';
+11
View File
@@ -3950,6 +3950,17 @@ class AppLocalizationsFr extends AppLocalizations {
String get trackCoverReplaceNotice =>
'La pochette sélectionnée remplacera la pochette actuellement intégrée lorsque vous appuierez sur « Enregistrer ».';
@override
String get trackCoverResolution => 'Cover resolution';
@override
String get trackCoverResolutionHint =>
'Sets the longest edge when saved. Enlarging does not add image detail.';
@override
String get trackCoverResizeFailed =>
'The cover image could not be resized. Please try another size or image.';
@override
String get actionStop => 'Arrêter';
+11
View File
@@ -3846,6 +3846,17 @@ class AppLocalizationsId extends AppLocalizations {
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get trackCoverResolution => 'Resolusi cover';
@override
String get trackCoverResolutionHint =>
'Mengatur sisi terpanjang saat disimpan. Memperbesar gambar tidak menambah detail.';
@override
String get trackCoverResizeFailed =>
'Ukuran cover tidak dapat diubah. Coba ukuran atau gambar lain.';
@override
String get actionStop => 'Stop';
+11
View File
@@ -3838,6 +3838,17 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get trackCoverResolution => 'Cover resolution';
@override
String get trackCoverResolutionHint =>
'Sets the longest edge when saved. Enlarging does not add image detail.';
@override
String get trackCoverResizeFailed =>
'The cover image could not be resized. Please try another size or image.';
@override
String get actionStop => 'Stop';
+11
View File
@@ -3739,6 +3739,17 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackCoverReplaceNotice => '저장을 탭하면 선택한 표지가 현재 내장된 표지를 대체합니다';
@override
String get trackCoverResolution => 'Cover resolution';
@override
String get trackCoverResolutionHint =>
'Sets the longest edge when saved. Enlarging does not add image detail.';
@override
String get trackCoverResizeFailed =>
'The cover image could not be resized. Please try another size or image.';
@override
String get actionStop => '중지';
+11
View File
@@ -3843,6 +3843,17 @@ class AppLocalizationsPt extends AppLocalizations {
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get trackCoverResolution => 'Cover resolution';
@override
String get trackCoverResolutionHint =>
'Sets the longest edge when saved. Enlarging does not add image detail.';
@override
String get trackCoverResizeFailed =>
'The cover image could not be resized. Please try another size or image.';
@override
String get actionStop => 'Stop';
+11
View File
@@ -3880,6 +3880,17 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get trackCoverResolution => 'Cover resolution';
@override
String get trackCoverResolutionHint =>
'Sets the longest edge when saved. Enlarging does not add image detail.';
@override
String get trackCoverResizeFailed =>
'The cover image could not be resized. Please try another size or image.';
@override
String get actionStop => 'Stop';
+11
View File
@@ -3879,6 +3879,17 @@ class AppLocalizationsTr extends AppLocalizations {
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get trackCoverResolution => 'Cover resolution';
@override
String get trackCoverResolutionHint =>
'Sets the longest edge when saved. Enlarging does not add image detail.';
@override
String get trackCoverResizeFailed =>
'The cover image could not be resized. Please try another size or image.';
@override
String get actionStop => 'Stop';
+11
View File
@@ -3897,6 +3897,17 @@ class AppLocalizationsUk extends AppLocalizations {
String get trackCoverReplaceNotice =>
'The selected cover will replace the current embedded cover when you tap Save.';
@override
String get trackCoverResolution => 'Cover resolution';
@override
String get trackCoverResolutionHint =>
'Sets the longest edge when saved. Enlarging does not add image detail.';
@override
String get trackCoverResizeFailed =>
'The cover image could not be resized. Please try another size or image.';
@override
String get actionStop => 'Stop';
+12
View File
@@ -4992,6 +4992,18 @@
"@trackCoverReplaceNotice": {
"description": "Notice shown when a new cover has been selected but not saved yet"
},
"trackCoverResolution": "Cover resolution",
"@trackCoverResolution": {
"description": "Label for selecting the embedded cover art resolution"
},
"trackCoverResolutionHint": "Sets the longest edge when saved. Enlarging does not add image detail.",
"@trackCoverResolutionHint": {
"description": "Explanation below the embedded cover art resolution selector"
},
"trackCoverResizeFailed": "The cover image could not be resized. Please try another size or image.",
"@trackCoverResizeFailed": {
"description": "Error shown when resizing cover art before saving metadata fails"
},
"actionStop": "Stop",
"@actionStop": {
"description": "Generic action - stop"
+3
View File
@@ -1656,6 +1656,9 @@
"description": "App bar title for the backup and restore page"
},
"trackCoverReplaceNotice": "The selected cover will replace the current embedded cover when you tap Save.",
"trackCoverResolution": "Resolusi cover",
"trackCoverResolutionHint": "Mengatur sisi terpanjang saat disimpan. Memperbesar gambar tidak menambah detail.",
"trackCoverResizeFailed": "Ukuran cover tidak dapat diubah. Coba ukuran atau gambar lain.",
"cacheSectionMaintenance": "Maintenance",
"@cacheSectionMaintenance": {
"description": "Section header for cleanup actions"
+174 -33
View File
@@ -42,6 +42,7 @@ class _EditMetadataSheet extends StatefulWidget {
}
class _EditMetadataSheetState extends State<_EditMetadataSheet> {
static const _coverResizeDimensions = <int>[500, 1000, 1500, 2000, 3000];
static final RegExp _metadataCollapsePattern = RegExp(r'[^a-z0-9]+');
static final RegExp _metadataWhitespacePattern = RegExp(r'\s+');
static final RegExp _spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
@@ -58,6 +59,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
String? _currentCoverPath;
String? _currentCoverTempDir;
bool _loadingCurrentCover = false;
int? _coverMaxDimension;
String? _selectedMetadataProviderId;
_AutoFillPreview? _autoFillPreview;
final GlobalKey<ScaffoldMessengerState> _sheetMessengerKey =
@@ -181,6 +183,101 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
});
}
Future<void> _showCoverResolutionPicker() async {
FocusScope.of(context).unfocus();
final currentValue = _coverMaxDimension ?? 0;
final selectedValue = await showModalBottomSheet<int>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
builder: (sheetContext) {
final cs = Theme.of(sheetContext).colorScheme;
final options = <({int value, String label})>[
(value: 0, label: sheetContext.l10n.trackConvertOriginal),
for (final dimension in _coverResizeDimensions)
(value: dimension, label: '$dimension px'),
];
return SafeArea(
top: false,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.72,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
sheetContext.l10n.trackCoverResolution,
style: Theme.of(sheetContext).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 16),
child: SettingsGroup(
children: [
for (var index = 0; index < options.length; index++)
Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
),
leading: Icon(
options[index].value == 0
? Icons.photo_size_select_actual_outlined
: Icons.photo_size_select_large_outlined,
color: options[index].value == currentValue
? cs.primary
: cs.onSurfaceVariant,
),
title: Text(options[index].label),
selected: options[index].value == currentValue,
selectedColor: cs.primary,
trailing: options[index].value == currentValue
? const Icon(Icons.check_rounded)
: null,
onTap: () => Navigator.pop(
sheetContext,
options[index].value,
),
),
if (index != options.length - 1)
Divider(
height: 1,
indent: 60,
endIndent: 20,
color: cs.outlineVariant.withValues(
alpha: 0.3,
),
),
],
),
],
),
),
),
],
),
),
);
},
);
if (!mounted || selectedValue == null) return;
setState(() {
_coverMaxDimension = selectedValue == 0 ? null : selectedValue;
});
}
Widget _metadataProviderOption({
required BuildContext context,
required String id,
@@ -934,14 +1031,11 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
results = await PlatformBridge.customSearchWithExtension(
selectedProvider.id,
query,
options: {
'limit': 5,
if (trackFilter != null) 'filter': trackFilter,
},
options: {'limit': 5, 'filter': ?trackFilter},
);
} else {
results = await PlatformBridge.searchTracksWithMetadataProvider(
selectedProvider!.id,
selectedProvider.id,
query,
limit: 5,
);
@@ -1334,29 +1428,55 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
Future<void> _save() async {
setState(() => _saving = true);
final metadata = <String, String>{
'title': _titleCtrl.text,
'artist': _artistCtrl.text,
'album': _albumCtrl.text,
'album_artist': _albumArtistCtrl.text,
'date': _dateCtrl.text,
'track_number': _trackNumCtrl.text,
'track_total': _trackTotalCtrl.text,
'disc_number': _discNumCtrl.text,
'disc_total': _discTotalCtrl.text,
'genre': _genreCtrl.text,
'isrc': _isrcCtrl.text,
'lyrics': _lyricsCtrl.text,
'label': _labelCtrl.text,
'copyright': _copyrightCtrl.text,
'composer': _composerCtrl.text,
'comment': _commentCtrl.text,
'cover_path': _selectedCoverPath ?? '',
'artist_tag_mode': widget.artistTagMode,
};
final noCoverMessage = context.l10n.trackCoverNoEmbeddedArt;
final resizeFailedMessage = context.l10n.trackCoverResizeFailed;
Directory? resizedCoverTempDir;
try {
String? coverPathForSave = _selectedCoverPath;
final requestedCoverDimension = _coverMaxDimension;
if (requestedCoverDimension != null) {
final sourceCoverPath = _selectedCoverPath ?? _currentCoverPath;
if (!_hasValue(sourceCoverPath)) {
throw StateError(noCoverMessage);
}
resizedCoverTempDir = await Directory.systemTemp.createTemp(
'edit_resized_cover_',
);
final resizedCoverPath =
'${resizedCoverTempDir.path}${Platform.pathSeparator}'
'cover_${requestedCoverDimension}px.jpg';
final resized = await FFmpegService.resizeCoverArt(
inputPath: sourceCoverPath!,
outputPath: resizedCoverPath,
maxDimension: requestedCoverDimension,
);
if (!resized) {
throw StateError(resizeFailedMessage);
}
coverPathForSave = resizedCoverPath;
}
final metadata = <String, String>{
'title': _titleCtrl.text,
'artist': _artistCtrl.text,
'album': _albumCtrl.text,
'album_artist': _albumArtistCtrl.text,
'date': _dateCtrl.text,
'track_number': _trackNumCtrl.text,
'track_total': _trackTotalCtrl.text,
'disc_number': _discNumCtrl.text,
'disc_total': _discTotalCtrl.text,
'genre': _genreCtrl.text,
'isrc': _isrcCtrl.text,
'lyrics': _lyricsCtrl.text,
'label': _labelCtrl.text,
'copyright': _copyrightCtrl.text,
'composer': _composerCtrl.text,
'comment': _commentCtrl.text,
'cover_path': coverPathForSave ?? '',
'artist_tag_mode': widget.artistTagMode,
};
final result = await PlatformBridge.editFileMetadata(
widget.filePath,
metadata,
@@ -1371,7 +1491,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
);
}
setState(() => _saving = false);
return;
}
@@ -1447,7 +1566,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
// Lyrics/ReplayGain preservation is best-effort.
}
String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath;
String? existingCoverPath = coverPathForSave ?? _currentCoverPath;
String? extractedCoverPath;
if (existingCoverPath == null || existingCoverPath.isEmpty) {
// Preserve current embedded cover when user does not pick a new one.
@@ -1513,15 +1632,15 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (mounted) {
_showSheetSnackBar(context.l10n.metadataSaveFailedFfmpeg);
}
setState(() => _saving = false);
return;
}
if (tempPath != null && safUri != null) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) {
_showSheetSnackBar(context.l10n.metadataSaveFailedStorage);
setState(() => _saving = false);
if (!ok) {
if (mounted) {
_showSheetSnackBar(context.l10n.metadataSaveFailedStorage);
}
return;
}
}
@@ -1537,6 +1656,13 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
);
}
} finally {
if (resizedCoverTempDir != null) {
try {
if (await resizedCoverTempDir.exists()) {
await resizedCoverTempDir.delete(recursive: true);
}
} catch (_) {}
}
if (mounted) setState(() => _saving = false);
}
}
@@ -2114,6 +2240,21 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
],
const SizedBox(height: 12),
_selectionField(
label: context.l10n.trackCoverResolution,
value: _coverMaxDimension == null
? context.l10n.trackConvertOriginal
: '${_coverMaxDimension!} px',
enabled: !_saving,
onTap: _showCoverResolutionPicker,
),
Text(
context.l10n.trackCoverResolutionHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
],
const SizedBox(height: 8),
],
+79 -1
View File
@@ -1,7 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart';
@@ -296,6 +296,84 @@ class FFmpegService {
}
}
@visibleForTesting
static List<String> buildCoverResizeArguments({
required String inputPath,
required String outputPath,
required int maxDimension,
}) {
if (maxDimension < 64 || maxDimension > 8192) {
throw ArgumentError.value(
maxDimension,
'maxDimension',
'Must be between 64 and 8192 pixels',
);
}
return [
'-y',
'-i',
inputPath,
'-map_metadata',
'-1',
'-an',
'-sn',
'-vf',
'scale=$maxDimension:$maxDimension:'
'force_original_aspect_ratio=decrease:flags=lanczos',
'-frames:v',
'1',
'-update',
'1',
'-q:v',
'2',
outputPath,
];
}
/// Resizes cover art so its longest edge is [maxDimension].
///
/// FFmpeg is allowed to upscale smaller sources because this is an explicit
/// user choice. Aspect ratio is preserved and the result is written as the
/// format implied by [outputPath].
static Future<bool> resizeCoverArt({
required String inputPath,
required String outputPath,
required int maxDimension,
}) async {
try {
final source = File(inputPath);
if (!await source.exists()) return false;
final result = await _executeWithArguments(
buildCoverResizeArguments(
inputPath: inputPath,
outputPath: outputPath,
maxDimension: maxDimension,
),
);
final output = File(outputPath);
final hasOutput = await output.exists() && await output.length() > 0;
if (!result.success || !hasOutput) {
_log.w(
'Cover resize failed (${result.returnCode}): '
'${_previewCommandForLog(result.output)}',
);
try {
if (await output.exists()) await output.delete();
} catch (_) {}
return false;
}
return true;
} catch (e) {
_log.w('Cover resize failed: $e');
try {
final output = File(outputPath);
if (await output.exists()) await output.delete();
} catch (_) {}
return false;
}
}
static Future<String?> probePrimaryAudioCodec(String filePath) async {
try {
final session = await FFprobeKit.getMediaInformation(filePath);
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
void main() {
test('cover resize uses safe arguments and preserves aspect ratio', () {
final arguments = FFmpegService.buildCoverResizeArguments(
inputPath: '/tmp/input cover.jpg',
outputPath: '/tmp/output cover.jpg',
maxDimension: 1500,
);
expect(arguments, containsAllInOrder(['-i', '/tmp/input cover.jpg']));
expect(
arguments,
containsAllInOrder([
'-vf',
'scale=1500:1500:force_original_aspect_ratio=decrease:flags=lanczos',
]),
);
expect(arguments, containsAllInOrder(['-frames:v', '1', '-update', '1']));
expect(arguments.last, '/tmp/output cover.jpg');
});
test('cover resize rejects unreasonable dimensions', () {
expect(
() => FFmpegService.buildCoverResizeArguments(
inputPath: 'input.jpg',
outputPath: 'output.jpg',
maxDimension: 0,
),
throwsArgumentError,
);
});
}