mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
feat(metadata): add embedded cover resize options
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => '중지';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user