From 7f4ace6d1b7e0055ea50383563a3b15c6549d933 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 1 Aug 2026 18:34:25 +0700 Subject: [PATCH] feat(metadata): show current cover dimensions --- lib/screens/track_metadata_edit_sheet.dart | 78 +++++++++++++++++++++- lib/services/ffmpeg_service.dart | 32 +++++++++ test/ffmpeg_cover_resize_test.dart | 18 +++++ 3 files changed, 126 insertions(+), 2 deletions(-) diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index ad2c4d40..154b375c 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -60,6 +60,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { String? _currentCoverTempDir; bool _loadingCurrentCover = false; int? _coverMaxDimension; + final Map _coverDimensions = {}; + final Set _coverDimensionLoads = {}; String? _selectedMetadataProviderId; _AutoFillPreview? _autoFillPreview; final GlobalKey _sheetMessengerKey = @@ -195,7 +197,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { builder: (sheetContext) { final cs = Theme.of(sheetContext).colorScheme; final options = <({int value, String label})>[ - (value: 0, label: sheetContext.l10n.trackConvertOriginal), + ( + value: 0, + label: _originalCoverResolutionLabel( + sheetContext.l10n.trackConvertOriginal, + ), + ), for (final dimension in _coverResizeDimensions) (value: dimension, label: '$dimension px'), ]; @@ -371,6 +378,29 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; + String _originalCoverResolutionLabel(String originalLabel) { + final sourcePath = _selectedCoverPath ?? _currentCoverPath; + final dimensions = sourcePath == null ? null : _coverDimensions[sourcePath]; + if (dimensions == null) return originalLabel; + return '$originalLabel · ${dimensions.width} × ${dimensions.height} px'; + } + + Future _loadCoverDimensions(String path) async { + if (_coverDimensions.containsKey(path) || !_coverDimensionLoads.add(path)) { + return; + } + final dimensions = await FFmpegService.probeImageDimensions(path); + if (!mounted) return; + setState(() { + _coverDimensionLoads.remove(path); + final isActivePath = + path == _currentCoverPath || path == _selectedCoverPath; + if (isActivePath && dimensions != null) { + _coverDimensions[path] = dimensions; + } + }); + } + String _resolveImageExtension(String? ext, Uint8List? bytes) { final normalized = (ext ?? '').toLowerCase(); if (normalized == 'png' || @@ -406,9 +436,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Future _cleanupSelectedCoverTemp() async { final dirPath = _selectedCoverTempDir; + final coverPath = _selectedCoverPath; _selectedCoverPath = null; _selectedCoverTempDir = null; _selectedCoverName = null; + if (coverPath != null) { + _coverDimensions.remove(coverPath); + _coverDimensionLoads.remove(coverPath); + } if (dirPath == null || dirPath.isEmpty) return; try { final dir = Directory(dirPath); @@ -420,8 +455,13 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Future _cleanupCurrentCoverTemp() async { final dirPath = _currentCoverTempDir; + final coverPath = _currentCoverPath; _currentCoverPath = null; _currentCoverTempDir = null; + if (coverPath != null) { + _coverDimensions.remove(coverPath); + _coverDimensionLoads.remove(coverPath); + } if (dirPath == null || dirPath.isEmpty) return; try { final dir = Directory(dirPath); @@ -469,11 +509,19 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } final oldDir = _currentCoverTempDir; + final oldCoverPath = _currentCoverPath; setState(() { + if (oldCoverPath != null && oldCoverPath != newCoverPath) { + _coverDimensions.remove(oldCoverPath); + _coverDimensionLoads.remove(oldCoverPath); + } _currentCoverPath = newCoverPath; _currentCoverTempDir = newCoverDir; _loadingCurrentCover = false; }); + if (newCoverPath != null) { + unawaited(_loadCoverDimensions(newCoverPath)); + } if (oldDir != null && oldDir.isNotEmpty && oldDir != newCoverDir) { try { final dir = Directory(oldDir); @@ -526,6 +574,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { _selectedCoverTempDir = tempDir.path; _selectedCoverName = picked.name; }); + unawaited(_loadCoverDimensions(tempPath)); } catch (e) { if (!mounted) return; _showSheetSnackBar(context.l10n.snackbarError(context.friendlyError(e))); @@ -1344,6 +1393,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { _selectedCoverTempDir = tempDir.path; _selectedCoverName = _EditMetadataSheet._onlineCoverSentinel; filledCount++; + unawaited(_loadCoverDimensions(coverOutput)); } } else { await tempDir.delete(recursive: true); @@ -2244,7 +2294,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { _selectionField( label: context.l10n.trackCoverResolution, value: _coverMaxDimension == null - ? context.l10n.trackConvertOriginal + ? _originalCoverResolutionLabel( + context.l10n.trackConvertOriginal, + ) : '${_coverMaxDimension!} px', enabled: !_saving, onTap: _showCoverResolutionPicker, @@ -2266,6 +2318,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { required String path, required String label, }) { + final dimensions = _coverDimensions[path]; + final loadingDimensions = _coverDimensionLoads.contains(path); return Column( children: [ Container( @@ -2311,6 +2365,26 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { maxLines: 1, overflow: TextOverflow.ellipsis, ), + const SizedBox(height: 3), + if (dimensions != null) + Text( + '${dimensions.width} × ${dimensions.height} px', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: cs.primary, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + else if (loadingDimensions) + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: cs.primary, + ), + ), ], ); } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index dfbcdb37..185ebd5b 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -374,6 +374,38 @@ class FFmpegService { } } + @visibleForTesting + static ({int width, int height})? imageDimensionsFromProperties( + Map properties, + ) { + final width = int.tryParse(properties['width']?.toString() ?? ''); + final height = int.tryParse(properties['height']?.toString() ?? ''); + if (width == null || height == null || width <= 0 || height <= 0) { + return null; + } + return (width: width, height: height); + } + + /// Reads image dimensions without decoding the full bitmap into Dart memory. + static Future<({int width, int height})?> probeImageDimensions( + String filePath, + ) async { + try { + final session = await FFprobeKit.getMediaInformation(filePath); + final info = session.getMediaInformation(); + if (info == null) return null; + for (final stream in info.getStreams()) { + final properties = + stream.getAllProperties() ?? const {}; + final dimensions = imageDimensionsFromProperties(properties); + if (dimensions != null) return dimensions; + } + } catch (e) { + _log.w('Cover dimension probe failed for $filePath: $e'); + } + return null; + } + static Future probePrimaryAudioCodec(String filePath) async { try { final session = await FFprobeKit.getMediaInformation(filePath); diff --git a/test/ffmpeg_cover_resize_test.dart b/test/ffmpeg_cover_resize_test.dart index a0449945..ca6cfb7c 100644 --- a/test/ffmpeg_cover_resize_test.dart +++ b/test/ffmpeg_cover_resize_test.dart @@ -31,4 +31,22 @@ void main() { throwsArgumentError, ); }); + + test('cover dimensions require positive width and height', () { + expect( + FFmpegService.imageDimensionsFromProperties({ + 'width': '1900', + 'height': 1500, + }), + (width: 1900, height: 1500), + ); + expect( + FFmpegService.imageDimensionsFromProperties({'width': 0, 'height': 1500}), + isNull, + ); + expect( + FFmpegService.imageDimensionsFromProperties({'width': 1500}), + isNull, + ); + }); }