diff --git a/lib/controllers/metadata_editor_controller.dart b/lib/controllers/metadata_editor_controller.dart new file mode 100644 index 00000000..b17d474b --- /dev/null +++ b/lib/controllers/metadata_editor_controller.dart @@ -0,0 +1,105 @@ +import 'package:flutter/widgets.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; + +/// Editable tags and request generations, independent of sheet navigation. +class MetadataEditorController { + static const fieldKeys = [ + 'title', + 'artist', + 'album', + 'album_artist', + 'date', + 'track_number', + 'total_tracks', + 'disc_number', + 'total_discs', + 'genre', + 'isrc', + 'lyrics', + 'label', + 'copyright', + 'composer', + 'comment', + 'album_type', + 'explicit', + 'upc', + 'cover', + ]; + + final Map _fields; + final Set autoFillFields = {}; + bool explicit; + int _generation = 0; + bool _disposed = false; + + MetadataEditorController(Map initialValues) + : _fields = { + for (final key in fieldKeys) + if (key != 'explicit' && key != 'cover') + key: TextEditingController(text: initialValues[key] ?? ''), + }, + explicit = parseExplicitFlag(initialValues['explicit']) == true { + for (final field in _fields.values) { + var previousText = field.text; + field.addListener(() { + if (field.text == previousText) return; + previousText = field.text; + invalidateLookup(); + }); + } + } + + TextEditingController? operator [](String key) => _fields[key]; + + Map get values => { + for (final entry in _fields.entries) entry.key: entry.value.text, + 'explicit': explicit ? '1' : '0', + }; + + int beginLookup() => ++_generation; + void invalidateLookup() => _generation++; + bool isCurrent(int generation) => !_disposed && generation == _generation; + + int apply(Map values) { + var filled = 0; + for (final key in autoFillFields) { + final value = values[key]; + if (value == null) continue; + if (key == 'explicit') { + final parsed = parseExplicitFlag(value); + if (parsed == null) continue; + explicit = parsed; + } else { + final field = _fields[key]; + if (field == null) continue; + field.text = value; + } + filled++; + } + return filled; + } + + void selectEmptyFields({required bool hasCover}) { + autoFillFields + ..clear() + ..addAll(_fields.keys.where((key) => _fields[key]!.text.trim().isEmpty)); + if (!hasCover) autoFillFields.add('cover'); + } + + Map saveMetadata({required String artistTagMode}) { + final tags = values; + tags['track_total'] = tags.remove('total_tracks')!; + tags['disc_total'] = tags.remove('total_discs')!; + tags['compilation'] = + tags['album_type']!.trim().toLowerCase() == 'compilation' ? '1' : '0'; + tags['artist_tag_mode'] = artistTagMode; + return tags; + } + + void dispose() { + _disposed = true; + for (final field in _fields.values) { + field.dispose(); + } + } +} diff --git a/lib/screens/track_metadata_actions.dart b/lib/screens/track_metadata_actions.dart index 734aeca9..3f57d512 100644 --- a/lib/screens/track_metadata_actions.dart +++ b/lib/screens/track_metadata_actions.dart @@ -61,7 +61,7 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { backgroundColor: context.isMornye ? colorScheme.surfaceContainerLow : colorScheme.surface, - builder: (sheetContext) => _EditMetadataSheet( + builder: (sheetContext) => EditMetadataSheet( colorScheme: colorScheme, initialValues: initialValues, filePath: cleanFilePath, diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 04c11171..4ec98b0a 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -1,4 +1,36 @@ -part of 'track_metadata_screen.dart'; +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; +import 'package:spotiflac_android/controllers/metadata_editor_controller.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/metadata_cover_resources.dart'; +import 'package:spotiflac_android/services/metadata_autofill_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/theme/mornye_icons.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/widgets/app_action_button.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_choice_chip.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/widgets/metadata_candidate_tile.dart'; +import 'package:spotiflac_android/widgets/metadata_text_field.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +final _log = AppLogger('MetadataEditor'); class _AutoFillPreview { final Map values; @@ -18,137 +50,7 @@ class _AutoFillPreview { }); } -class _MetadataCandidateArtwork extends StatefulWidget { - final String? coverUrl; - - const _MetadataCandidateArtwork({required this.coverUrl}); - - @override - State<_MetadataCandidateArtwork> createState() => - _MetadataCandidateArtworkState(); -} - -class _MetadataCandidateArtworkState extends State<_MetadataCandidateArtwork> { - String? _tempDir; - String? _coverPath; - ({int width, int height, int bytes})? _details; - - @override - void initState() { - super.initState(); - if (widget.coverUrl?.isNotEmpty == true) { - unawaited(_loadCover()); - } - } - - Future _loadCover() async { - final tempDir = await Directory.systemTemp.createTemp( - 'metadata_candidate_cover_', - ); - final path = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; - try { - final result = await PlatformBridge.downloadCoverToFile( - widget.coverUrl!, - path, - ); - if (result['error'] != null || !await File(path).exists()) { - await tempDir.delete(recursive: true); - return; - } - final dimensions = await FFmpegService.probeImageDimensions(path); - final bytes = await File(path).length(); - if (!mounted) { - await tempDir.delete(recursive: true); - return; - } - setState(() { - _tempDir = tempDir.path; - _coverPath = path; - if (dimensions != null) { - _details = ( - width: dimensions.width, - height: dimensions.height, - bytes: bytes, - ); - } - }); - } catch (_) { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - } - } - - @override - void dispose() { - final tempDir = _tempDir; - if (tempDir != null) { - unawaited(() async { - try { - final directory = Directory(tempDir); - if (await directory.exists()) await directory.delete(recursive: true); - } catch (_) {} - }()); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final coverUrl = widget.coverUrl; - final details = _details; - final image = _coverPath != null - ? Image.file( - File(_coverPath!), - width: 56, - height: 56, - fit: BoxFit.cover, - cacheWidth: (56 * MediaQuery.devicePixelRatioOf(context)).round(), - cacheHeight: (56 * MediaQuery.devicePixelRatioOf(context)).round(), - filterQuality: FilterQuality.low, - ) - : coverUrl != null - ? CachedCoverImage( - imageUrl: coverUrl, - width: 56, - height: 56, - fit: BoxFit.cover, - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: const Icon(Icons.music_note), - ); - - return SizedBox( - width: 72, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect(borderRadius: BorderRadius.circular(10), child: image), - if (details != null) ...[ - const SizedBox(height: 3), - Text( - '${details.width}×${details.height} · ' - '${(details.bytes / 1024).toStringAsFixed(1)} KB', - maxLines: 2, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.primary, - fontSize: 9, - height: 1.1, - ), - ), - ], - ], - ), - ); - } -} - -class _EditMetadataSheet extends StatefulWidget { +class EditMetadataSheet extends StatefulWidget { static const _onlineCoverSentinel = '__online_cover__'; final ColorScheme colorScheme; final Map initialValues; @@ -157,7 +59,8 @@ class _EditMetadataSheet extends StatefulWidget { final int durationMs; final String artistTagMode; - const _EditMetadataSheet({ + const EditMetadataSheet({ + super.key, required this.colorScheme, required this.initialValues, required this.filePath, @@ -167,21 +70,15 @@ class _EditMetadataSheet extends StatefulWidget { }); @override - State<_EditMetadataSheet> createState() => _EditMetadataSheetState(); + State createState() => _EditMetadataSheetState(); } -class _EditMetadataSheetState extends State<_EditMetadataSheet> { +class _EditMetadataSheetState extends State { static const _coverResizeDimensions = [500, 1000, 1500, 2000, 3000]; - static final RegExp _metadataCollapsePattern = RegExp( - r'[^\p{L}\p{M}\p{N}]+', - unicode: true, - ); - static final RegExp _metadataWhitespacePattern = RegExp(r'\s+'); - static final RegExp _spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); - static final RegExp _deezerTrackIdPattern = RegExp(r'^\d+$'); - static final RegExp _isrcPattern = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$'); bool _saving = false; + final _covers = MetadataCoverResources(); + final _autofill = MetadataAutofillService(); bool _showAdvanced = false; bool _showAutoFill = false; bool _fetching = false; @@ -199,27 +96,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final GlobalKey _sheetMessengerKey = GlobalKey(); - final Set _autoFillFields = {}; - - List _orderedMetadataProviders(ExtensionState state) { - final providersById = { - for (final extension in state.extensions) - if (extension.enabled && extension.hasMetadataProvider) - extension.id: extension, - }; - final ordered = []; - for (final id in state.metadataProviderPriority) { - final extension = providersById.remove(id); - if (extension != null) ordered.add(extension); - } - final remaining = providersById.values.toList() - ..sort( - (a, b) => - a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), - ); - return [...ordered, ...remaining]; - } - String _metadataProviderName(String? providerId) { final normalized = providerId?.trim() ?? ''; if (normalized.isEmpty) { @@ -468,6 +344,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } void _invalidateAutoFillPreview() { + _form.invalidateLookup(); final preview = _autoFillPreview; _autoFillPreview = null; final coverPath = preview?.coverPath; @@ -477,19 +354,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } final tempDir = preview?.coverTempDir; if (tempDir != null && tempDir.isNotEmpty) { - unawaited(_deleteTempDirectory(tempDir)); + unawaited(_covers.release(tempDir)); } } - Future _deleteTempDirectory(String path) async { - try { - final directory = Directory(path); - if (await directory.exists()) { - await directory.delete(recursive: true); - } - } catch (_) {} - } - void _showSheetSnackBar(String message) { final snackBar = SnackBar(content: Text(message)); final messenger = _sheetMessengerKey.currentState; @@ -502,48 +370,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackBar); } - static const _fieldDefs = { - 'title': 'title', - 'artist': 'artist', - 'album': 'album', - 'album_artist': 'album_artist', - 'date': 'date', - 'track_number': 'track_number', - 'total_tracks': 'total_tracks', - 'disc_number': 'disc_number', - 'total_discs': 'total_discs', - 'genre': 'genre', - 'isrc': 'isrc', - 'lyrics': 'lyrics', - 'label': 'label', - 'copyright': 'copyright', - 'composer': 'composer', - 'comment': 'comment', - 'album_type': 'album_type', - 'explicit': 'explicit', - 'upc': 'upc', - 'cover': 'cover', - }; - - late final TextEditingController _titleCtrl; - late final TextEditingController _artistCtrl; - late final TextEditingController _albumCtrl; - late final TextEditingController _albumArtistCtrl; - late final TextEditingController _dateCtrl; - late final TextEditingController _trackNumCtrl; - late final TextEditingController _trackTotalCtrl; - late final TextEditingController _discNumCtrl; - late final TextEditingController _discTotalCtrl; - late final TextEditingController _genreCtrl; - late final TextEditingController _isrcCtrl; - late final TextEditingController _lyricsCtrl; - late final TextEditingController _labelCtrl; - late final TextEditingController _copyrightCtrl; - late final TextEditingController _composerCtrl; - late final TextEditingController _commentCtrl; - late final TextEditingController _albumTypeCtrl; - late final TextEditingController _upcCtrl; - bool _explicit = false; + late final MetadataEditorController _form; bool _fetchingMusicBrainz = false; bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; @@ -560,24 +387,11 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return '${(bytes / 1024).toStringAsFixed(1)} KB'; } - Future<({int width, int height, int bytes})?> _readCoverDetails( - String path, - ) async { - final dimensions = await FFmpegService.probeImageDimensions(path); - if (dimensions == null) return null; - try { - final bytes = await File(path).length(); - return (width: dimensions.width, height: dimensions.height, bytes: bytes); - } catch (_) { - return null; - } - } - Future _loadCoverDetails(String path) async { if (_coverDetails.containsKey(path) || !_coverDetailLoads.add(path)) { return; } - final details = await _readCoverDetails(path); + final details = await _covers.readDetails(path); if (!mounted) return; setState(() { _coverDetailLoads.remove(path); @@ -589,45 +403,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { }); } - Future< - ({ - String path, - String tempDir, - ({int width, int height, int bytes})? details, - })? - > - _downloadAutoFillCoverPreview(String coverUrl) async { - final tempDir = await Directory.systemTemp.createTemp( - 'autofill_cover_preview_', - ); - final coverPath = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; - try { - final result = await PlatformBridge.downloadCoverToFile( - coverUrl, - coverPath, - ); - if (result['error'] != null || result['success'] == false) { - throw StateError( - result['error']?.toString() ?? 'Cover download failed', - ); - } - final file = File(coverPath); - if (!await file.exists() || await file.length() <= 0) { - await tempDir.delete(recursive: true); - return null; - } - return ( - path: coverPath, - tempDir: tempDir.path, - details: await _readCoverDetails(coverPath), - ); - } catch (e) { - _log.w('Could not download metadata artwork: $e'); - await _deleteTempDirectory(tempDir.path); - return null; - } - } - String _resolveImageExtension(String? ext, Uint8List? bytes) { final normalized = (ext ?? '').toLowerCase(); if (normalized == 'png' || @@ -662,146 +437,60 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } Future _cleanupSelectedCoverTemp() async { - final dirPath = _selectedCoverTempDir; - final coverPath = _selectedCoverPath; + final directory = _selectedCoverTempDir; + final path = _selectedCoverPath; _selectedCoverPath = null; _selectedCoverTempDir = null; _selectedCoverName = null; - if (coverPath != null) { - _coverDetails.remove(coverPath); - _coverDetailLoads.remove(coverPath); - } - if (dirPath == null || dirPath.isEmpty) return; - try { - final dir = Directory(dirPath); - if (await dir.exists()) { - await dir.delete(recursive: true); - } - } catch (_) {} - } - - Future _cleanupCurrentCoverTemp() async { - final dirPath = _currentCoverTempDir; - final coverPath = _currentCoverPath; - _currentCoverPath = null; - _currentCoverTempDir = null; - if (coverPath != null) { - _coverDetails.remove(coverPath); - _coverDetailLoads.remove(coverPath); - } - if (dirPath == null || dirPath.isEmpty) return; - try { - final dir = Directory(dirPath); - if (await dir.exists()) { - await dir.delete(recursive: true); - } - } catch (_) {} + _coverDetails.remove(path); + _coverDetailLoads.remove(path); + await _covers.release(directory); } Future _loadCurrentCoverPreview() async { if (_loadingCurrentCover) return; setState(() => _loadingCurrentCover = true); - String? newCoverPath; - String? newCoverDir; - try { - final tempDir = await Directory.systemTemp.createTemp( - 'edit_existing_cover_', - ); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}existing_cover.jpg'; - final coverResult = await PlatformBridge.extractCoverToFile( - widget.filePath, - coverOutput, - ); - if (coverResult['error'] == null && await File(coverOutput).exists()) { - newCoverPath = coverOutput; - newCoverDir = tempDir.path; - } else { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - } - } catch (_) {} - - if (!mounted) { - if (newCoverDir != null) { - try { - final dir = Directory(newCoverDir); - if (await dir.exists()) { - await dir.delete(recursive: true); - } - } catch (_) {} - } - return; - } - - final oldDir = _currentCoverTempDir; - final oldCoverPath = _currentCoverPath; + final preview = await _covers.extract(widget.filePath); + if (!mounted) return; + final oldDirectory = _currentCoverTempDir; + _coverDetails.remove(_currentCoverPath); + _coverDetailLoads.remove(_currentCoverPath); setState(() { - if (oldCoverPath != null && oldCoverPath != newCoverPath) { - _coverDetails.remove(oldCoverPath); - _coverDetailLoads.remove(oldCoverPath); - } - _currentCoverPath = newCoverPath; - _currentCoverTempDir = newCoverDir; + _currentCoverPath = preview?.path; + _currentCoverTempDir = preview?.tempDir; _loadingCurrentCover = false; + if (preview?.details case final details?) { + _coverDetails[preview!.path] = details; + } }); - if (newCoverPath != null) { - unawaited(_loadCoverDetails(newCoverPath)); - } - if (oldDir != null && oldDir.isNotEmpty && oldDir != newCoverDir) { - try { - final dir = Directory(oldDir); - if (await dir.exists()) { - await dir.delete(recursive: true); - } - } catch (_) {} - } + await _covers.release(oldDirectory); } Future _pickCoverImage() async { try { final picked = await FilePicker.pickFile(type: FileType.image); - if (picked == null) return; - + if (picked == null || !mounted) return; final sourcePath = picked.path; - final pickedExtension = p.extension(picked.name).replaceFirst('.', ''); - Uint8List? bytes; - final needsByteFallback = !_hasValue(sourcePath); - if (needsByteFallback) { - bytes = await picked.readAsBytes(); - } - final extension = _resolveImageExtension(pickedExtension, bytes); - - final tempDir = await Directory.systemTemp.createTemp('edit_cover_'); - final tempPath = - '${tempDir.path}${Platform.pathSeparator}cover.$extension'; - - if (sourcePath != null && sourcePath.isNotEmpty) { - final sourceFile = File(sourcePath); - if (!await sourceFile.exists()) { - throw Exception('Selected image is not accessible'); - } - await sourceFile.copy(tempPath); - } else if (bytes != null && bytes.isNotEmpty) { - await File(tempPath).writeAsBytes(bytes, flush: true); - } else { - throw Exception('Unable to read selected image'); - } - + final bytes = _hasValue(sourcePath) ? null : await picked.readAsBytes(); + if (!mounted) return; + final extension = _resolveImageExtension( + p.extension(picked.name).replaceFirst('.', ''), + bytes, + ); + final preview = await _covers.copyPicked( + extension: extension, + sourcePath: sourcePath, + bytes: bytes, + ); + if (!mounted) return; await _cleanupSelectedCoverTemp(); - if (!mounted) { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - return; - } + if (!mounted) return; setState(() { - _selectedCoverPath = tempPath; - _selectedCoverTempDir = tempDir.path; + _selectedCoverPath = preview.path; + _selectedCoverTempDir = preview.tempDir; _selectedCoverName = picked.name; }); - unawaited(_loadCoverDetails(tempPath)); + unawaited(_loadCoverDetails(preview.path)); } catch (e) { if (!mounted) return; _showSheetSnackBar(context.l10n.snackbarError(context.friendlyError(e))); @@ -810,386 +499,54 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { String _fieldLabel(String key) { final l10n = context.l10n; - switch (key) { - case 'title': - return l10n.editMetadataFieldTitle; - case 'artist': - return l10n.editMetadataFieldArtist; - case 'album': - return l10n.editMetadataFieldAlbum; - case 'album_artist': - return l10n.editMetadataFieldAlbumArtist; - case 'date': - return l10n.editMetadataFieldDate; - case 'track_number': - return l10n.editMetadataFieldTrackNum; - case 'total_tracks': - return l10n.editMetadataFieldTrackTotal; - case 'disc_number': - return l10n.editMetadataFieldDiscNum; - case 'total_discs': - return l10n.editMetadataFieldDiscTotal; - case 'genre': - return l10n.editMetadataFieldGenre; - case 'isrc': - return l10n.editMetadataFieldIsrc; - case 'lyrics': - return l10n.trackLyrics; - case 'label': - return l10n.editMetadataFieldLabel; - case 'copyright': - return l10n.editMetadataFieldCopyright; - case 'composer': - return l10n.editMetadataFieldComposer; - case 'comment': - return l10n.editMetadataFieldComment; - case 'album_type': - return l10n.trackAlbumType; - case 'explicit': - return l10n.editMetadataFieldExplicit; - case 'upc': - return l10n.editMetadataFieldUpc; - case 'cover': - return l10n.editMetadataFieldCover; - default: - return key; - } - } - - TextEditingController? _controllerForKey(String key) { - switch (key) { - case 'title': - return _titleCtrl; - case 'artist': - return _artistCtrl; - case 'album': - return _albumCtrl; - case 'album_artist': - return _albumArtistCtrl; - case 'date': - return _dateCtrl; - case 'track_number': - return _trackNumCtrl; - case 'total_tracks': - return _trackTotalCtrl; - case 'disc_number': - return _discNumCtrl; - case 'total_discs': - return _discTotalCtrl; - case 'genre': - return _genreCtrl; - case 'isrc': - return _isrcCtrl; - case 'lyrics': - return _lyricsCtrl; - case 'label': - return _labelCtrl; - case 'copyright': - return _copyrightCtrl; - case 'composer': - return _composerCtrl; - case 'comment': - return _commentCtrl; - case 'album_type': - return _albumTypeCtrl; - case 'upc': - return _upcCtrl; - default: - return null; - } + return switch (key) { + 'title' => l10n.editMetadataFieldTitle, + 'artist' => l10n.editMetadataFieldArtist, + 'album' => l10n.editMetadataFieldAlbum, + 'album_artist' => l10n.editMetadataFieldAlbumArtist, + 'date' => l10n.editMetadataFieldDate, + 'track_number' => l10n.editMetadataFieldTrackNum, + 'total_tracks' => l10n.editMetadataFieldTrackTotal, + 'disc_number' => l10n.editMetadataFieldDiscNum, + 'total_discs' => l10n.editMetadataFieldDiscTotal, + 'genre' => l10n.editMetadataFieldGenre, + 'isrc' => l10n.editMetadataFieldIsrc, + 'lyrics' => l10n.trackLyrics, + 'label' => l10n.editMetadataFieldLabel, + 'copyright' => l10n.editMetadataFieldCopyright, + 'composer' => l10n.editMetadataFieldComposer, + 'comment' => l10n.editMetadataFieldComment, + 'album_type' => l10n.trackAlbumType, + 'explicit' => l10n.editMetadataFieldExplicit, + 'upc' => l10n.editMetadataFieldUpc, + 'cover' => l10n.editMetadataFieldCover, + _ => key, + }; } void _selectAllFields() { setState(() { _invalidateAutoFillPreview(); - _autoFillFields.addAll(_fieldDefs.keys); + _form.autoFillFields.addAll(MetadataEditorController.fieldKeys); }); } void _selectEmptyFields() { setState(() { _invalidateAutoFillPreview(); - _autoFillFields.clear(); - for (final key in _fieldDefs.keys) { - if (key == 'explicit') continue; - if (key == 'cover') { - if (!_hasValue(_currentCoverPath) && !_hasValue(_selectedCoverPath)) { - _autoFillFields.add(key); - } - continue; - } - final ctrl = _controllerForKey(key); - if (ctrl != null && ctrl.text.trim().isEmpty) { - _autoFillFields.add(key); - } - } + _form.selectEmptyFields( + hasCover: _hasValue(_currentCoverPath) || _hasValue(_selectedCoverPath), + ); }); } void _selectNoFields() { setState(() { _invalidateAutoFillPreview(); - _autoFillFields.clear(); + _form.autoFillFields.clear(); }); } - String _normalizeMetadataText(String value) { - final collapsed = value - .toLowerCase() - .replaceAll(_metadataCollapsePattern, ' ') - .trim(); - return collapsed.replaceAll(_metadataWhitespacePattern, ' '); - } - - bool _looksLikeIsrc(String value) { - return _isrcPattern.hasMatch(value.trim().toUpperCase()); - } - - String? _extractRawSpotifyTrackIdFromValue(Object? value) { - final raw = value?.toString().trim() ?? ''; - if (raw.isEmpty) return null; - - if (_spotifyTrackIdPattern.hasMatch(raw)) { - return raw; - } - - if (raw.startsWith('spotify:')) { - final parts = raw.split(':'); - final last = parts.isNotEmpty ? parts.last.trim() : ''; - if (_spotifyTrackIdPattern.hasMatch(last)) { - return last; - } - return null; - } - - final uri = Uri.tryParse(raw); - if (uri != null && - uri.host.contains('spotify.com') && - uri.pathSegments.length >= 2 && - uri.pathSegments.first == 'track') { - final candidate = uri.pathSegments[1].trim(); - if (_spotifyTrackIdPattern.hasMatch(candidate)) { - return candidate; - } - } - - return null; - } - - String? _extractRawDeezerTrackIdFromValue(Object? value) { - final raw = value?.toString().trim() ?? ''; - if (raw.isEmpty) return null; - - if (_deezerTrackIdPattern.hasMatch(raw)) { - return raw; - } - - if (raw.startsWith('deezer:')) { - final parts = raw.split(':'); - final last = parts.isNotEmpty ? parts.last.trim() : ''; - if (_deezerTrackIdPattern.hasMatch(last)) { - return last; - } - } - - final uri = Uri.tryParse(raw); - if (uri != null && uri.host.contains('deezer.com')) { - final trackIndex = uri.pathSegments.indexOf('track'); - if (trackIndex >= 0 && trackIndex + 1 < uri.pathSegments.length) { - final candidate = uri.pathSegments[trackIndex + 1].trim(); - if (_deezerTrackIdPattern.hasMatch(candidate)) { - return candidate; - } - } - } - - return null; - } - - String? _extractRawSpotifyTrackId(Map track) { - for (final candidate in [track['spotify_id'], track['id']]) { - final spotifyId = _extractRawSpotifyTrackIdFromValue(candidate); - if (spotifyId != null) return spotifyId; - } - - final externalLinks = track['external_links']; - if (externalLinks is Map) { - final spotifyId = _extractRawSpotifyTrackIdFromValue( - externalLinks['spotify'], - ); - if (spotifyId != null) return spotifyId; - } - - return null; - } - - String? _extractRawDeezerTrackId(Map track) { - for (final candidate in [ - track['deezer_id'], - track['spotify_id'], - track['id'], - ]) { - final deezerId = _extractRawDeezerTrackIdFromValue(candidate); - if (deezerId != null) return deezerId; - } - - final externalLinks = track['external_links']; - if (externalLinks is Map) { - final deezerId = _extractRawDeezerTrackIdFromValue( - externalLinks['deezer'], - ); - if (deezerId != null) return deezerId; - } - - return null; - } - - Map _unwrapTrackPayload(Map payload) { - final track = payload['track']; - if (track is Map) { - return track; - } - return payload; - } - - void _mergeOnlineTrackData( - Map enriched, - Map track, - ) { - void put(String key, Object? value) { - final text = value?.toString().trim() ?? ''; - if (text.isNotEmpty && text != 'null') { - enriched[key] = text; - } - } - - put('title', track['name'] ?? track['title']); - put('artist', track['artists'] ?? track['artist']); - put('album', track['album_name'] ?? track['album']); - put('album_artist', track['album_artist']); - put('date', track['release_date']); - put('track_number', track['track_number']); - put('total_tracks', track['total_tracks']); - put('disc_number', track['disc_number']); - put('total_discs', track['total_discs']); - put('isrc', track['isrc']); - put('genre', track['genre']); - put('label', track['label']); - put('copyright', track['copyright']); - put('composer', track['composer']); - put('comment', track['comment']); - put('album_type', track['album_type'] ?? track['albumType']); - put('explicit', track['explicit'] ?? track['is_explicit']); - put('upc', track['upc'] ?? track['barcode']); - } - - int _metadataMatchScore( - Map track, { - required String currentTitle, - required String currentArtist, - required String currentAlbum, - required String currentIsrc, - }) { - var score = 0; - - final candidateIsrc = (track['isrc']?.toString() ?? '') - .trim() - .toUpperCase(); - if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) { - score += 10000; - } - - final candidateTitle = _normalizeMetadataText( - (track['name'] ?? track['title'] ?? '').toString(), - ); - final candidateArtist = _normalizeMetadataText( - (track['artists'] ?? track['artist'] ?? '').toString(), - ); - final candidateAlbum = _normalizeMetadataText( - (track['album_name'] ?? track['album'] ?? '').toString(), - ); - - if (currentTitle.isNotEmpty && candidateTitle.isNotEmpty) { - if (candidateTitle == currentTitle) { - score += 400; - } else if (candidateTitle.contains(currentTitle) || - currentTitle.contains(candidateTitle)) { - score += 180; - } - } - - if (currentArtist.isNotEmpty && candidateArtist.isNotEmpty) { - if (candidateArtist == currentArtist) { - score += 320; - } else if (candidateArtist.contains(currentArtist) || - currentArtist.contains(candidateArtist)) { - score += 140; - } - } - - if (currentAlbum.isNotEmpty && candidateAlbum.isNotEmpty) { - if (candidateAlbum == currentAlbum) { - score += 120; - } else if (candidateAlbum.contains(currentAlbum) || - currentAlbum.contains(candidateAlbum)) { - score += 50; - } - } - - return score; - } - - bool _metadataTextMatches(String current, String candidate) { - if (current.isEmpty || candidate.isEmpty) return false; - return current == candidate || - candidate.contains(current) || - current.contains(candidate); - } - - bool _metadataMatchIsConfident( - Map track, { - required String currentTitle, - required String currentArtist, - required String currentAlbum, - required String currentIsrc, - }) { - final candidateIsrc = (track['isrc']?.toString() ?? '') - .trim() - .toUpperCase(); - if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) { - return true; - } - - final candidateTitle = _normalizeMetadataText( - (track['name'] ?? track['title'] ?? '').toString(), - ); - final candidateArtist = _normalizeMetadataText( - (track['artists'] ?? track['artist'] ?? '').toString(), - ); - final candidateAlbum = _normalizeMetadataText( - (track['album_name'] ?? track['album'] ?? '').toString(), - ); - - final titleMatches = _metadataTextMatches(currentTitle, candidateTitle); - final artistMatches = _metadataTextMatches(currentArtist, candidateArtist); - final albumMatches = _metadataTextMatches(currentAlbum, candidateAlbum); - - if (currentTitle.isNotEmpty && currentArtist.isNotEmpty) { - return titleMatches && artistMatches; - } - if (currentTitle.isNotEmpty && currentAlbum.isNotEmpty) { - return titleMatches && albumMatches; - } - if (currentTitle.isNotEmpty) { - return titleMatches; - } - if (currentAlbum.isNotEmpty) { - return albumMatches; - } - - return false; - } - Future>> _searchAutoFillCandidates({ required String query, required bool usesAutomaticProvider, @@ -1198,27 +555,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { bool allowVerificationRetry = true, }) async { try { - if (usesAutomaticProvider) { - return await PlatformBridge.searchTracksWithMetadataProviders( - query, - limit: 5, - ); - } - - final provider = selectedProvider!; - if (provider.hasCustomSearch) { - final trackFilter = provider.searchBehavior?.filterIdForKind('track'); - return await PlatformBridge.customSearchWithExtension( - provider.id, - query, - options: {'limit': 5, 'filter': ?trackFilter}, - ); - } - return await PlatformBridge.searchTracksWithMetadataProvider( - provider.id, - query, - limit: 5, - ); + return await _autofill.search(query, provider: selectedProvider); } catch (error) { if (!allowVerificationRetry || !isExtensionVerificationRequired(error)) { rethrow; @@ -1254,30 +591,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } - String? _metadataCandidateCoverUrl(Map track) { - String? fromValue(Object? value) { - if (value is String) { - final normalized = value.trim(); - return normalized.isEmpty ? null : normalized; - } - if (value is List) { - for (final item in value) { - final resolved = fromValue(item); - if (resolved != null) return resolved; - } - } - if (value is Map) { - for (final key in const ['url', 'original', 'large', 'src']) { - final resolved = fromValue(value[key]); - if (resolved != null) return resolved; - } - } - return null; - } - - return fromValue(track['cover_url']) ?? fromValue(track['images']); - } - Future?> _showAutoFillCandidatePicker({ required List> candidates, required String sourceName, @@ -1301,54 +614,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { _editorOptions( children: [ for (var index = 0; index < candidates.length; index++) ...[ - Builder( - builder: (context) { - final candidate = candidates[index]; - final title = - (candidate['name'] ?? candidate['title'] ?? '') - .toString() - .trim(); - final artists = - (candidate['artists'] ?? candidate['artist'] ?? '') - .toString() - .trim(); - final album = - (candidate['album_name'] ?? candidate['album'] ?? '') - .toString() - .trim(); - final date = - candidate['release_date']?.toString().trim() ?? ''; - final isrc = candidate['isrc']?.toString().trim() ?? ''; - final coverUrl = _metadataCandidateCoverUrl(candidate); - final subtitle = [ - artists, - album, - if (date.isNotEmpty) date, - if (isrc.isNotEmpty) 'ISRC ${formatIsrcForDisplay(isrc)}', - ].where((value) => value.isNotEmpty).join('\n'); - - return ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 6, - ), - leading: _MetadataCandidateArtwork(coverUrl: coverUrl), - title: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: subtitle.isEmpty - ? null - : Text( - subtitle, - maxLines: 4, - overflow: TextOverflow.ellipsis, - ), - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.pop(sheetContext, candidate), - ); - }, + MetadataCandidateTile( + candidate: candidates[index], + onTap: () => Navigator.pop(sheetContext, candidates[index]), ), if (index != candidates.length - 1) Divider( @@ -1368,7 +636,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } Future _fetchAutoFillPreview() async { - if (_autoFillFields.isEmpty) { + if (_fetching || _saving) return; + if (_form.autoFillFields.isEmpty) { _showSheetSnackBar(context.l10n.editMetadataAutoFillNoneSelected); return; } @@ -1377,18 +646,22 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { _fetching = true; _invalidateAutoFillPreview(); }); + final generation = _form.beginLookup(); + bool isCurrent() => mounted && _form.isCurrent(generation); + final currentValues = _form.values; + final fields = Set.of(_form.autoFillFields); final settingsNotifier = ProviderScope.containerOf( context, listen: false, ).read(settingsProvider.notifier); try { - final title = _titleCtrl.text.trim(); - final artist = _artistCtrl.text.trim(); - final album = _albumCtrl.text.trim(); - final albumArtist = _albumArtistCtrl.text.trim(); + final title = currentValues['title']!.trim(); + final artist = currentValues['artist']!.trim(); + final album = currentValues['album']!.trim(); + final albumArtist = currentValues['album_artist']!.trim(); final searchArtist = primaryArtistName(artist, albumArtist: albumArtist); - final currentIsrc = _isrcCtrl.text.trim().toUpperCase(); + final currentIsrc = currentValues['isrc']!.trim().toUpperCase(); final configuredProviderId = _selectedMetadataProviderId?.trim(); final extensionState = ProviderScope.containerOf( context, @@ -1407,11 +680,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final selectedProviderId = selectedProvider?.id; final usesAutomaticProvider = selectedProviderId == null || selectedProviderId.isEmpty; - final shouldFetchLyrics = _autoFillFields.contains('lyrics'); - final needsTrackLookup = _autoFillFields.any((key) => key != 'lyrics'); + final needsTrackLookup = fields.any((key) => key != 'lyrics'); Map? best; - String? deezerId; - String? lyricsSourceName; final queryParts = []; if (title.isNotEmpty) queryParts.add(title); @@ -1425,9 +695,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return; } - final normalizedTitle = _normalizeMetadataText(title); - final normalizedArtist = _normalizeMetadataText(searchArtist); - final normalizedAlbum = _normalizeMetadataText(album); + final normalizedTitle = _autofill.normalizeText(title); + final normalizedArtist = _autofill.normalizeText(searchArtist); + final normalizedAlbum = _autofill.normalizeText(album); if (needsTrackLookup) { final query = queryParts.join(' '); @@ -1438,7 +708,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { extensionState: extensionState, ); - if (!mounted) return; + if (!mounted || !isCurrent()) return; if (results.isEmpty) { _showSheetSnackBar(context.l10n.editMetadataAutoFillNoResults); @@ -1447,14 +717,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final rankedResults = [...results] ..sort((a, b) { - final aScore = _metadataMatchScore( + final aScore = _autofill.matchScore( a, currentTitle: normalizedTitle, currentArtist: normalizedArtist, currentAlbum: normalizedAlbum, currentIsrc: currentIsrc, ); - final bScore = _metadataMatchScore( + final bScore = _autofill.matchScore( b, currentTitle: normalizedTitle, currentArtist: normalizedArtist, @@ -1466,7 +736,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (usesAutomaticProvider) { best = rankedResults.first; - if (!_metadataMatchIsConfident( + if (!_autofill.matchIsConfident( best, currentTitle: normalizedTitle, currentArtist: normalizedArtist, @@ -1482,7 +752,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { currentTitle: title, currentArtist: searchArtist, ); - if (!mounted || best == null) return; + if (!mounted || !isCurrent() || best == null) return; } if (best == null) { @@ -1490,62 +760,13 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return; } - var resolvedBest = best; - final matchedProviderId = usesAutomaticProvider - ? resolvedBest['provider_id']?.toString().trim() ?? '' - : selectedProviderId; - if (matchedProviderId.isNotEmpty) { - final trackId = resolvedBest['id']?.toString().trim() ?? ''; - if (trackId.isNotEmpty) { - try { - final details = await PlatformBridge.getProviderMetadata( - matchedProviderId, - 'track', - trackId, - ); - final mergedDetails = {...resolvedBest}; - for (final entry in _unwrapTrackPayload(details).entries) { - final value = entry.value; - if (value != null && value.toString().trim().isNotEmpty) { - mergedDetails[entry.key] = value; - } - } - resolvedBest = mergedDetails; - } catch (e) { - _log.w( - 'Detailed metadata lookup failed for ' - '$matchedProviderId/$trackId: $e', - ); - } - } - - if (_autoFillFields.contains('cover') && - _metadataCandidateCoverUrl(resolvedBest) == null) { - final albumId = resolvedBest['album_id']?.toString().trim() ?? ''; - if (albumId.isNotEmpty) { - try { - final details = await PlatformBridge.getProviderMetadata( - matchedProviderId, - 'album', - albumId, - ); - final albumData = details['album_info'] ?? details['album']; - final coverUrl = _metadataCandidateCoverUrl( - albumData is Map ? albumData : details, - ); - if (coverUrl != null) { - resolvedBest = {...resolvedBest, 'cover_url': coverUrl}; - } - } catch (e) { - _log.w( - 'Album artwork lookup failed for ' - '$matchedProviderId/$albumId: $e', - ); - } - } - } - } - best = resolvedBest; + best = await _autofill.resolveDetails( + best, + providerId: usesAutomaticProvider + ? best['provider_id']?.toString().trim() ?? '' + : selectedProviderId, + includeCover: fields.contains('cover'), + ); } final selectedBest = best; @@ -1553,167 +774,20 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { throw StateError('No metadata match resolved for auto-fill'); } - final enriched = {}; - if (selectedBest != null) { - enriched.addAll({ - 'title': (selectedBest['name'] ?? '').toString(), - 'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '') - .toString(), - 'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '') - .toString(), - 'album_artist': (selectedBest['album_artist'] ?? '').toString(), - 'date': (selectedBest['release_date'] ?? '').toString(), - 'track_number': (selectedBest['track_number'] ?? '').toString(), - 'total_tracks': (selectedBest['total_tracks'] ?? '').toString(), - 'disc_number': (selectedBest['disc_number'] ?? '').toString(), - 'total_discs': (selectedBest['total_discs'] ?? '').toString(), - 'isrc': (selectedBest['isrc'] ?? '').toString(), - 'composer': (selectedBest['composer'] ?? '').toString(), - }); - _mergeOnlineTrackData(enriched, selectedBest); - } - - final enrichedIsrc = (enriched['isrc'] ?? '').trim(); - final needsIsrc = - _autoFillFields.contains('isrc') && enrichedIsrc.isEmpty; - final needsExtended = - _autoFillFields.contains('genre') || - _autoFillFields.contains('label') || - _autoFillFields.contains('copyright') || - _autoFillFields.contains('composer'); - - final rawSpotifyId = selectedBest == null - ? _extractRawSpotifyTrackIdFromValue(widget.sourceTrackId) - : _extractRawSpotifyTrackId(selectedBest); - - deezerId ??= selectedBest == null - ? null - : _extractRawDeezerTrackId(selectedBest); - final candidateIsrc = enrichedIsrc.toUpperCase(); - final deezerLookupIsrc = _looksLikeIsrc(currentIsrc) - ? currentIsrc - : (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : ''); - - if (usesAutomaticProvider && (needsIsrc || needsExtended)) { - try { - if (deezerId == null && deezerLookupIsrc.isNotEmpty) { - final deezerResult = await PlatformBridge.searchDeezerByISRC( - deezerLookupIsrc, - ); - deezerId = _extractRawDeezerTrackId(deezerResult); - _mergeOnlineTrackData(enriched, deezerResult); - } - - if (deezerId == null && rawSpotifyId != null) { - // Spotify IDs can be mapped through SongLink to a Deezer track. - final deezerData = await PlatformBridge.convertSpotifyToDeezer( - 'track', - rawSpotifyId, - ); - final trackData = deezerData['track']; - if (trackData is Map) { - deezerId = _extractRawDeezerTrackId(trackData); - _mergeOnlineTrackData(enriched, trackData); - } - deezerId ??= _extractRawDeezerTrackId(deezerData); - } - } catch (_) { - // Deezer resolution is best-effort - } - } - - if (!mounted) return; - - if (usesAutomaticProvider && - needsIsrc && - (enriched['isrc'] ?? '').trim().isEmpty && - deezerId != null) { - try { - final deezerMeta = await PlatformBridge.getProviderMetadata( - 'deezer', - 'track', - deezerId, - ); - final trackData = _unwrapTrackPayload(deezerMeta); - _mergeOnlineTrackData(enriched, trackData); - final deezerIsrc = (trackData['isrc'] ?? '').toString().trim(); - if (deezerIsrc.isNotEmpty) { - enriched['isrc'] = deezerIsrc; - } - } catch (_) {} - } - - if (!mounted) return; - - if (usesAutomaticProvider && needsExtended && deezerId != null) { - try { - final extended = await PlatformBridge.getDeezerExtendedMetadata( - deezerId, - ); - if (extended != null) { - enriched['genre'] = extended['genre'] ?? ''; - enriched['label'] = extended['label'] ?? ''; - enriched['copyright'] = extended['copyright'] ?? ''; - } - } catch (_) { - // Extended metadata is best-effort - } - } - - if (shouldFetchLyrics) { - await settingsNotifier.syncLyricsSettingsToBackend(); - - if (!mounted) return; - - final lyricsTitle = - ((selectedBest?['name'] ?? selectedBest?['title'] ?? title) - .toString()) - .trim(); - final lyricsArtist = - ((selectedBest?['artists'] ?? selectedBest?['artist'] ?? artist) - .toString()) - .trim(); - - if (lyricsTitle.isNotEmpty && lyricsArtist.isNotEmpty) { - try { - final lyricsResult = await PlatformBridge.getLyricsLRCWithSource( - rawSpotifyId ?? '', - lyricsTitle, - lyricsArtist, - durationMs: widget.durationMs, - ); - final lyricsText = lyricsResult['lyrics']?.toString().trim() ?? ''; - final lyricsSource = - lyricsResult['source']?.toString().trim() ?? ''; - if (lyricsSource.isNotEmpty) lyricsSourceName = lyricsSource; - final instrumental = - (lyricsResult['instrumental'] as bool? ?? false) || - lyricsText == '[instrumental:true]'; - if (!instrumental && lyricsText.isNotEmpty) { - enriched['lyrics'] = lyricsText; - } - } catch (e) { - _log.w('Lyrics autofill failed: $e'); - } - } - } - - if (!mounted) return; - - final availableValues = {}; - for (final entry in enriched.entries) { - final value = entry.value.trim(); - final isExplicitValue = entry.key == 'explicit'; - if (value.isNotEmpty && - (isExplicitValue || value != '0') && - value != 'null') { - availableValues[entry.key] = value; - } - } - final coverUrl = selectedBest == null - ? null - : _metadataCandidateCoverUrl(selectedBest); - final hasSelectedValue = _autoFillFields.any( + final result = await _autofill.enrich( + selectedBest, + currentValues: currentValues, + fields: fields, + providerId: selectedProviderId, + sourceTrackId: widget.sourceTrackId, + durationMs: widget.durationMs, + syncLyricsSettings: settingsNotifier.syncLyricsSettingsToBackend, + isCurrent: isCurrent, + ); + if (!mounted || !isCurrent() || result == null) return; + final availableValues = result.values; + final coverUrl = result.coverUrl; + final hasSelectedValue = _form.autoFillFields.any( (key) => key == 'cover' ? coverUrl != null && coverUrl.isNotEmpty : availableValues.containsKey(key), @@ -1724,25 +798,21 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } final coverPreview = - _autoFillFields.contains('cover') && + _form.autoFillFields.contains('cover') && coverUrl != null && coverUrl.isNotEmpty - ? await _downloadAutoFillCoverPreview(coverUrl) + ? await _covers.download(coverUrl) : null; - if (!mounted) { + if (!mounted || !isCurrent()) { if (coverPreview != null) { - await _deleteTempDirectory(coverPreview.tempDir); + await _covers.release(coverPreview.tempDir); } return; } - final resolvedSourceId = selectedProviderId?.isNotEmpty == true - ? selectedProviderId! - : (selectedBest?['provider_id']?.toString().trim() ?? ''); - final resolvedSourceName = - selectedBest == null && lyricsSourceName?.isNotEmpty == true - ? lyricsSourceName! - : _metadataProviderName(resolvedSourceId); + final resolvedSourceName = result.lyricsSource?.isNotEmpty == true + ? result.lyricsSource! + : _metadataProviderName(result.sourceId); setState(() { _autoFillPreview = _AutoFillPreview( values: availableValues, @@ -1758,7 +828,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { }); } catch (e, stackTrace) { _log.e('Metadata auto-fill failed: $e', e, stackTrace); - if (mounted) { + if (mounted && isCurrent()) { _showSheetSnackBar( context.l10n.snackbarError(context.friendlyError(e)), ); @@ -1774,36 +844,17 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { setState(() => _fetching = true); try { - var filledCount = 0; + var filledCount = _form.apply(preview.values); var coverDownloadFailed = false; - for (final key in _autoFillFields) { - if (key == 'cover') continue; - final value = preview.values[key]; - if (key == 'explicit' && value != null) { - final parsed = parseExplicitFlag(value); - if (parsed != null) { - _explicit = parsed; - filledCount++; - } - continue; - } - final ctrl = _controllerForKey(key); - if (value != null && ctrl != null) { - ctrl.text = value; - filledCount++; - } - } - if (_autoFillFields.contains('cover') && preview.coverUrl != null) { + if (_form.autoFillFields.contains('cover') && preview.coverUrl != null) { var coverPath = preview.coverPath; var coverTempDir = preview.coverTempDir; var coverDetails = preview.coverDetails; if (coverPath == null || coverTempDir == null || !await File(coverPath).exists()) { - final downloaded = await _downloadAutoFillCoverPreview( - preview.coverUrl!, - ); + final downloaded = await _covers.download(preview.coverUrl!); coverPath = downloaded?.path; coverTempDir = downloaded?.tempDir; coverDetails = downloaded?.details; @@ -1813,7 +864,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (mounted) { _selectedCoverPath = coverPath; _selectedCoverTempDir = coverTempDir; - _selectedCoverName = _EditMetadataSheet._onlineCoverSentinel; + _selectedCoverName = EditMetadataSheet._onlineCoverSentinel; if (coverDetails != null) { _coverDetails[coverPath] = coverDetails; } else { @@ -1863,60 +914,26 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { @override void initState() { super.initState(); - final v = widget.initialValues; - _titleCtrl = TextEditingController(text: v['title'] ?? ''); - _artistCtrl = TextEditingController(text: v['artist'] ?? ''); - _albumCtrl = TextEditingController(text: v['album'] ?? ''); - _albumArtistCtrl = TextEditingController(text: v['album_artist'] ?? ''); - _dateCtrl = TextEditingController(text: v['date'] ?? ''); - _trackNumCtrl = TextEditingController(text: v['track_number'] ?? ''); - _trackTotalCtrl = TextEditingController(text: v['total_tracks'] ?? ''); - _discNumCtrl = TextEditingController(text: v['disc_number'] ?? ''); - _discTotalCtrl = TextEditingController(text: v['total_discs'] ?? ''); - _genreCtrl = TextEditingController(text: v['genre'] ?? ''); - _isrcCtrl = TextEditingController(text: v['isrc'] ?? ''); - _lyricsCtrl = TextEditingController(text: v['lyrics'] ?? ''); - _labelCtrl = TextEditingController(text: v['label'] ?? ''); - _copyrightCtrl = TextEditingController(text: v['copyright'] ?? ''); - _composerCtrl = TextEditingController(text: v['composer'] ?? ''); - _commentCtrl = TextEditingController(text: v['comment'] ?? ''); - _albumTypeCtrl = TextEditingController(text: v['album_type'] ?? ''); - _upcCtrl = TextEditingController(text: v['upc'] ?? ''); - _explicit = parseExplicitFlag(v['explicit']) == true; - _loadCurrentCoverPreview(); + _form = MetadataEditorController(widget.initialValues); + unawaited(_loadCurrentCoverPreview()); } @override void dispose() { - _invalidateAutoFillPreview(); - unawaited(_cleanupSelectedCoverTemp()); - unawaited(_cleanupCurrentCoverTemp()); - _titleCtrl.dispose(); - _artistCtrl.dispose(); - _albumCtrl.dispose(); - _albumArtistCtrl.dispose(); - _dateCtrl.dispose(); - _trackNumCtrl.dispose(); - _trackTotalCtrl.dispose(); - _discNumCtrl.dispose(); - _discTotalCtrl.dispose(); - _genreCtrl.dispose(); - _isrcCtrl.dispose(); - _lyricsCtrl.dispose(); - _labelCtrl.dispose(); - _copyrightCtrl.dispose(); - _composerCtrl.dispose(); - _commentCtrl.dispose(); - _albumTypeCtrl.dispose(); - _upcCtrl.dispose(); + unawaited(_covers.dispose()); + _form.dispose(); super.dispose(); } Future _save() async { + if (_saving || _fetching || _fetchingMusicBrainz) return; setState(() => _saving = true); + final releaseCoverLease = _covers.retain(); + final metadata = _form.saveMetadata(artistTagMode: widget.artistTagMode); final noCoverMessage = context.l10n.trackCoverNoEmbeddedArt; final resizeFailedMessage = context.l10n.trackCoverResizeFailed; - Directory? resizedCoverTempDir; + String? resizedCoverTempDir; + String? extractedCoverTempDir; try { String? coverPathForSave = _selectedCoverPath; final requestedCoverDimension = _coverMaxDimension; @@ -1925,49 +942,19 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { 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) { + try { + final resized = await _covers.resize( + sourceCoverPath!, + requestedCoverDimension, + ); + resizedCoverTempDir = resized.tempDir; + coverPathForSave = resized.path; + } catch (_) { throw StateError(resizeFailedMessage); } - coverPathForSave = resizedCoverPath; } - final metadata = { - '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, - 'album_type': _albumTypeCtrl.text, - 'explicit': _explicit ? '1' : '0', - 'upc': _upcCtrl.text, - 'compilation': _albumTypeCtrl.text.trim().toLowerCase() == 'compilation' - ? '1' - : '0', - 'cover_path': coverPathForSave ?? '', - 'artist_tag_mode': widget.artistTagMode, - }; + metadata['cover_path'] = coverPathForSave ?? ''; final result = await PlatformBridge.editFileMetadata( widget.filePath, @@ -2063,26 +1050,11 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } String? existingCoverPath = coverPathForSave ?? _currentCoverPath; - String? extractedCoverPath; if (existingCoverPath == null || existingCoverPath.isEmpty) { // Preserve current embedded cover when user does not pick a new one. - try { - final tempDir = await Directory.systemTemp.createTemp('cover_'); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}cover.jpg'; - final coverResult = await PlatformBridge.extractCoverToFile( - ffmpegTarget, - coverOutput, - ); - if (coverResult['error'] == null) { - existingCoverPath = coverOutput; - extractedCoverPath = coverOutput; - } else { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - } - } catch (_) {} + final extractedCover = await _covers.extract(ffmpegTarget); + existingCoverPath = extractedCover?.path; + extractedCoverTempDir = extractedCover?.tempDir; } String? ffmpegResult; @@ -2110,20 +1082,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); } - // Cleanup extracted temp cover (manual selected cover is cleaned on dispose) - if (extractedCoverPath != null && extractedCoverPath.isNotEmpty) { - final extractedFile = File(extractedCoverPath); - try { - await extractedFile.delete(); - } catch (_) {} - try { - final dir = extractedFile.parent; - if (await dir.exists()) { - await dir.delete(recursive: true); - } - } catch (_) {} - } - if (ffmpegResult == null) { if (mounted) { _showSheetSnackBar(context.l10n.metadataSaveFailedFfmpeg); @@ -2152,13 +1110,11 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); } } finally { + await _covers.release(extractedCoverTempDir); if (resizedCoverTempDir != null) { - try { - if (await resizedCoverTempDir.exists()) { - await resizedCoverTempDir.delete(recursive: true); - } - } catch (_) {} + await _covers.release(resizedCoverTempDir); } + await releaseCoverLease(); if (mounted) setState(() => _saving = false); } } @@ -2211,7 +1167,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ) else AppActionButton( - onPressed: _save, + onPressed: _fetching || _fetchingMusicBrainz + ? null + : _save, icon: const Icon(Icons.check, size: 18), label: Text(context.l10n.dialogSave), style: FilledButton.styleFrom( @@ -2242,41 +1200,48 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { icon: Icons.info_outline, title: context.l10n.trackMetadata, children: [ - _field( + MetadataTextField( + cs, context.l10n.editMetadataFieldTitle, - _titleCtrl, + _form['title']!, ), - _field( + MetadataTextField( + cs, context.l10n.editMetadataFieldArtist, - _artistCtrl, + _form['artist']!, ), - _field( + MetadataTextField( + cs, context.l10n.editMetadataFieldAlbum, - _albumCtrl, + _form['album']!, ), - _field( + MetadataTextField( + cs, context.l10n.editMetadataFieldAlbumArtist, - _albumArtistCtrl, + _form['album_artist']!, ), - _field( + MetadataTextField( + cs, context.l10n.editMetadataFieldDate, - _dateCtrl, + _form['date']!, hint: context.l10n.editMetadataFieldDateHint, ), Row( children: [ Expanded( - child: _field( + child: MetadataTextField( + cs, context.l10n.editMetadataFieldTrackNum, - _trackNumCtrl, + _form['track_number']!, keyboard: TextInputType.number, ), ), const SizedBox(width: 12), Expanded( - child: _field( + child: MetadataTextField( + cs, context.l10n.editMetadataFieldTrackTotal, - _trackTotalCtrl, + _form['total_tracks']!, keyboard: TextInputType.number, ), ), @@ -2285,36 +1250,44 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Row( children: [ Expanded( - child: _field( + child: MetadataTextField( + cs, context.l10n.editMetadataFieldDiscNum, - _discNumCtrl, + _form['disc_number']!, keyboard: TextInputType.number, ), ), const SizedBox(width: 12), Expanded( - child: _field( + child: MetadataTextField( + cs, context.l10n.editMetadataFieldDiscTotal, - _discTotalCtrl, + _form['total_discs']!, keyboard: TextInputType.number, ), ), ], ), - _field( + MetadataTextField( + cs, context.l10n.editMetadataFieldGenre, - _genreCtrl, + _form['genre']!, + ), + MetadataTextField( + cs, + context.l10n.editMetadataFieldIsrc, + _form['isrc']!, ), - _field(context.l10n.editMetadataFieldIsrc, _isrcCtrl), ], ), _sectionCard( icon: Icons.lyrics_outlined, title: context.l10n.trackLyrics, children: [ - _field( + MetadataTextField( + cs, context.l10n.trackLyrics, - _lyricsCtrl, + _form['lyrics']!, maxLines: 8, keyboard: TextInputType.multiline, ), @@ -2328,34 +1301,39 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { expanded: _showAdvanced, children: [ if (_showAdvanced) ...[ - _field( + MetadataTextField( + cs, context.l10n.editMetadataFieldLabel, - _labelCtrl, + _form['label']!, ), - _field( + MetadataTextField( + cs, context.l10n.editMetadataFieldCopyright, - _copyrightCtrl, + _form['copyright']!, ), - _field( + MetadataTextField( + cs, context.l10n.editMetadataFieldComposer, - _composerCtrl, + _form['composer']!, ), - _field( + MetadataTextField( + cs, context.l10n.trackAlbumType, - _albumTypeCtrl, + _form['album_type']!, hint: context.l10n.editMetadataFieldAlbumTypeHint, ), _switchField( label: context.l10n.editMetadataFieldExplicit, subtitle: context.l10n.editMetadataFieldExplicitHint, - value: _explicit, + value: _form.explicit, onChanged: (value) => - setState(() => _explicit = value), + setState(() => _form.explicit = value), ), - _field( + MetadataTextField( + cs, context.l10n.editMetadataFieldUpc, - _upcCtrl, + _form['upc']!, hint: context.l10n.editMetadataFieldUpcHint, keyboard: TextInputType.number, inputFormatters: [ @@ -2363,9 +1341,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { LengthLimitingTextInputFormatter(18), ], ), - _field( + MetadataTextField( + cs, context.l10n.editMetadataFieldComment, - _commentCtrl, + _form['comment']!, maxLines: 3, ), ], @@ -2400,7 +1379,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Consumer( builder: (context, ref, _) { final extensionState = ref.watch(extensionProvider); - final providers = _orderedMetadataProviders(extensionState); + final providers = _autofill.orderedProviders(extensionState); final selectedProviderIsAvailable = providers.any( (extension) => extension.id == _selectedMetadataProviderId, ); @@ -2440,8 +1419,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Wrap( spacing: 8, runSpacing: 8, - children: _fieldDefs.keys.map((key) { - final selected = _autoFillFields.contains(key); + children: MetadataEditorController.fieldKeys.map((key) { + final selected = _form.autoFillFields.contains(key); if (context.isMornye) { return MornyeFilterChip( label: _fieldLabel(key), @@ -2455,9 +1434,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { : () => setState(() { _invalidateAutoFillPreview(); if (selected) { - _autoFillFields.remove(key); + _form.autoFillFields.remove(key); } else { - _autoFillFields.add(key); + _form.autoFillFields.add(key); } }), ); @@ -2471,9 +1450,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { setState(() { _invalidateAutoFillPreview(); if (val) { - _autoFillFields.add(key); + _form.autoFillFields.add(key); } else { - _autoFillFields.remove(key); + _form.autoFillFields.remove(key); } }); }, @@ -2485,7 +1464,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { width: double.infinity, child: AppActionButton( glass: false, - onPressed: (_fetching || _saving || _autoFillFields.isEmpty) + onPressed: (_fetching || _saving || _form.autoFillFields.isEmpty) ? null : _fetchAutoFillPreview, style: FilledButton.styleFrom( @@ -2549,7 +1528,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } Widget _buildAutoFillPreview(ColorScheme cs, _AutoFillPreview preview) { - final previewFields = _autoFillFields + final previewFields = _form.autoFillFields .where((key) { if (key == 'cover') return preview.coverUrl != null; return preview.values.containsKey(key); @@ -2672,25 +1651,27 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { /// Fills genre and album artist from MusicBrainz (keyed by ISRC) as /// editable suggestions; nothing is saved until the user taps Save. Future _fetchFromMusicBrainz() async { - final isrc = _isrcCtrl.text.trim(); + if (_saving || _fetching || _fetchingMusicBrainz) return; + final isrc = _form['isrc']!.text.trim(); if (isrc.isEmpty) { _showSheetSnackBar(context.l10n.editMetadataMusicBrainzNeedsIsrc); return; } setState(() => _fetchingMusicBrainz = true); + final generation = _form.beginLookup(); try { final tags = await PlatformBridge.fetchMusicBrainzTags( isrc: isrc, - albumName: _albumCtrl.text.trim(), + albumName: _form['album']!.text.trim(), ); - if (!mounted) return; + if (!mounted || !_form.isCurrent(generation)) return; var filled = 0; if (tags.genre.isNotEmpty) { - _genreCtrl.text = tags.genre; + _form['genre']!.text = tags.genre; filled++; } if (tags.albumArtist.isNotEmpty) { - _albumArtistCtrl.text = tags.albumArtist; + _form['album_artist']!.text = tags.albumArtist; filled++; } _showSheetSnackBar( @@ -2836,7 +1817,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { path: _selectedCoverPath!, label: _selectedCoverName == - _EditMetadataSheet._onlineCoverSentinel + EditMetadataSheet._onlineCoverSentinel ? context.l10n.trackCoverOnline : (_selectedCoverName ?? context.l10n.trackCoverSelected), @@ -2958,91 +1939,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); } - /// Fill for input fields, one step apart from the card so each field reads as - /// a distinct surface in light/dark/AMOLED. - Color _fieldFill(ColorScheme cs) { - if (context.isMornye) return Colors.transparent; - final isDark = Theme.of(context).brightness == Brightness.dark; - return isDark - ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), cs.surface) - : cs.surface; - } - - Widget _field( - String label, - TextEditingController controller, { - String? hint, - TextInputType? keyboard, - int maxLines = 1, - List? inputFormatters, - }) { - final cs = widget.colorScheme; - final radius = BorderRadius.circular(14); - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 6), - child: Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: cs.onSurfaceVariant, - fontWeight: FontWeight.w600, - letterSpacing: 0.1, - ), - ), - ), - if (context.isMornye) ...[ - CupertinoTextField( - controller: controller, - keyboardType: keyboard, - inputFormatters: inputFormatters, - maxLines: maxLines, - placeholder: hint, - cursorColor: cs.primary, - style: Theme.of(context).textTheme.bodyLarge, - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14), - decoration: null, - ), - const Divider(height: 0.5), - ] else - TextField( - controller: controller, - keyboardType: keyboard, - inputFormatters: inputFormatters, - maxLines: maxLines, - cursorColor: cs.primary, - style: Theme.of(context).textTheme.bodyLarge, - decoration: InputDecoration( - hintText: hint, - filled: true, - fillColor: _fieldFill(cs), - isDense: true, - border: OutlineInputBorder( - borderRadius: radius, - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: radius, - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: radius, - borderSide: BorderSide(color: cs.primary, width: 1.5), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 15, - ), - ), - ), - ], - ), - ); - } - Widget _switchField({ required String label, required String subtitle, @@ -3055,7 +1951,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { child: Column( children: [ Material( - color: _fieldFill(cs), + color: metadataEditorFieldFill(context, cs), borderRadius: BorderRadius.circular(14), clipBehavior: Clip.antiAlias, child: AppSwitchListTile( @@ -3103,7 +1999,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), CupertinoButton( - color: _fieldFill(cs), + color: metadataEditorFieldFill(context, cs), disabledColor: Colors.transparent, borderRadius: BorderRadius.circular(16), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14), @@ -3152,7 +2048,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), Material( - color: _fieldFill(cs), + color: metadataEditorFieldFill(context, cs), borderRadius: radius, clipBehavior: Clip.antiAlias, child: InkWell( @@ -3292,12 +2188,3 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); } } - -class _MetadataItem { - final String label; - final String value; - final String rawValue; - - _MetadataItem(this.label, this.value, {String? rawValue}) - : rawValue = rawValue ?? value; -} diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index f4f1a187..55fd1c2d 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -3,22 +3,12 @@ import 'dart:io'; import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; -import 'package:flutter/cupertino.dart' - show - CupertinoButton, - CupertinoIcons, - CupertinoTextField, - CupertinoActivityIndicator; -import 'package:spotiflac_android/widgets/app_action_button.dart'; -import 'package:spotiflac_android/widgets/app_choice_chip.dart'; -import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:flutter/cupertino.dart' show CupertinoButton, CupertinoIcons; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:path/path.dart' as p; import 'package:spotiflac_android/services/conversion_library_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/utils/file_access.dart'; @@ -31,7 +21,6 @@ import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/music_player_service.dart' show readPlaybackFileMetadataWithRetry; @@ -48,7 +37,6 @@ import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; -import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/user_facing_error.dart'; @@ -62,7 +50,6 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/theme/mornye_icons.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart' show HeaderMetaRow, HeaderMetaItem, HeaderCircleButton, HeaderFilledButton; -import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; import 'package:spotiflac_android/widgets/mornye_metadata_row.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; @@ -76,6 +63,7 @@ import 'package:spotiflac_android/widgets/metadata_barcode.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/constants/music_services.dart'; import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; +import 'package:spotiflac_android/screens/track_metadata_edit_sheet.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; part 'track_metadata_screen_cover.dart'; @@ -83,7 +71,6 @@ part 'track_metadata_screen_display.dart'; part 'track_metadata_screen_menu.dart'; part 'track_metadata_mornye.dart'; -part 'track_metadata_edit_sheet.dart'; part 'track_metadata_cards.dart'; part 'track_metadata_lyrics.dart'; part 'track_metadata_convert.dart'; @@ -91,6 +78,15 @@ part 'track_metadata_actions.dart'; final _log = AppLogger('TrackMetadata'); +class _MetadataItem { + final String label; + final String value; + final String rawValue; + + _MetadataItem(this.label, this.value, {String? rawValue}) + : rawValue = rawValue ?? value; +} + class _EmbeddedCoverPreviewCacheEntry { final String previewPath; final String? sourceValidationToken; diff --git a/lib/services/metadata_autofill_service.dart b/lib/services/metadata_autofill_service.dart new file mode 100644 index 00000000..ab37c589 --- /dev/null +++ b/lib/services/metadata_autofill_service.dart @@ -0,0 +1,537 @@ +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +class MetadataAutofillResult { + final Map values; + final String? coverUrl; + final String sourceId; + final String? lyricsSource; + + const MetadataAutofillResult({ + required this.values, + required this.coverUrl, + required this.sourceId, + required this.lyricsSource, + }); +} + +/// Provider lookup and enrichment without widget state or navigation access. +class MetadataAutofillService { + static final _log = AppLogger('MetadataAutofill'); + static final RegExp _metadataCollapsePattern = RegExp( + r'[^\p{L}\p{M}\p{N}]+', + unicode: true, + ); + static final RegExp _metadataWhitespacePattern = RegExp(r'\s+'); + static final RegExp _spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); + static final RegExp _deezerTrackIdPattern = RegExp(r'^\d+$'); + static final RegExp _isrcPattern = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$'); + + Future>> search( + String query, { + Extension? provider, + }) { + if (provider == null) { + return PlatformBridge.searchTracksWithMetadataProviders(query, limit: 5); + } + if (provider.hasCustomSearch) { + final filter = provider.searchBehavior?.filterIdForKind('track'); + return PlatformBridge.customSearchWithExtension( + provider.id, + query, + options: {'limit': 5, 'filter': ?filter}, + ); + } + return PlatformBridge.searchTracksWithMetadataProvider( + provider.id, + query, + limit: 5, + ); + } + + Future> resolveDetails( + Map candidate, { + required String providerId, + required bool includeCover, + }) async { + var resolved = candidate; + if (providerId.isEmpty) return resolved; + final trackId = candidate['id']?.toString().trim() ?? ''; + if (trackId.isNotEmpty) { + try { + final details = await PlatformBridge.getProviderMetadata( + providerId, + 'track', + trackId, + ); + resolved = { + ...candidate, + for (final entry in unwrapTrackPayload(details).entries) + if (entry.value != null && entry.value.toString().trim().isNotEmpty) + entry.key: entry.value, + }; + } catch (error) { + _log.w( + 'Detailed metadata lookup failed for $providerId/$trackId: $error', + ); + } + } + if (includeCover && coverUrl(resolved) == null) { + final albumId = resolved['album_id']?.toString().trim() ?? ''; + if (albumId.isNotEmpty) { + try { + final details = await PlatformBridge.getProviderMetadata( + providerId, + 'album', + albumId, + ); + final album = details['album_info'] ?? details['album']; + final url = coverUrl(album is Map ? album : details); + if (url != null) resolved = {...resolved, 'cover_url': url}; + } catch (error) { + _log.w( + 'Album artwork lookup failed for $providerId/$albumId: $error', + ); + } + } + } + return resolved; + } + + Future enrich( + Map? selectedBest, { + required Map currentValues, + required Set fields, + required String? providerId, + required String? sourceTrackId, + required int durationMs, + required Future Function() syncLyricsSettings, + required bool Function() isCurrent, + }) async { + if (!isCurrent()) return null; + final title = currentValues['title']?.trim() ?? ''; + final artist = currentValues['artist']?.trim() ?? ''; + final currentIsrc = currentValues['isrc']?.trim().toUpperCase() ?? ''; + final usesAutomaticProvider = providerId?.isNotEmpty != true; + final shouldFetchLyrics = fields.contains('lyrics'); + String? deezerId; + String? lyricsSourceName; + final enriched = {}; + if (selectedBest != null) { + mergeTrackData(enriched, selectedBest); + } + + final enrichedIsrc = (enriched['isrc'] ?? '').trim(); + final needsIsrc = fields.contains('isrc') && enrichedIsrc.isEmpty; + final needsExtended = + fields.contains('genre') || + fields.contains('label') || + fields.contains('copyright') || + fields.contains('composer'); + + final rawSpotifyId = selectedBest == null + ? spotifyTrackIdFromValue(sourceTrackId) + : spotifyTrackId(selectedBest); + + deezerId ??= selectedBest == null ? null : deezerTrackId(selectedBest); + final candidateIsrc = enrichedIsrc.toUpperCase(); + final deezerLookupIsrc = looksLikeIsrc(currentIsrc) + ? currentIsrc + : (looksLikeIsrc(candidateIsrc) ? candidateIsrc : ''); + + if (usesAutomaticProvider && (needsIsrc || needsExtended)) { + try { + if (deezerId == null && deezerLookupIsrc.isNotEmpty) { + final deezerResult = await PlatformBridge.searchDeezerByISRC( + deezerLookupIsrc, + ); + deezerId = deezerTrackId(deezerResult); + mergeTrackData(enriched, deezerResult); + } + + if (deezerId == null && rawSpotifyId != null) { + // Spotify IDs can be mapped through SongLink to a Deezer track. + final deezerData = await PlatformBridge.convertSpotifyToDeezer( + 'track', + rawSpotifyId, + ); + final trackData = deezerData['track']; + if (trackData is Map) { + deezerId = deezerTrackId(trackData); + mergeTrackData(enriched, trackData); + } + deezerId ??= deezerTrackId(deezerData); + } + } catch (_) { + // Deezer resolution is best-effort + } + } + + if (!isCurrent()) return null; + + if (usesAutomaticProvider && + needsIsrc && + (enriched['isrc'] ?? '').trim().isEmpty && + deezerId != null) { + try { + final deezerMeta = await PlatformBridge.getProviderMetadata( + 'deezer', + 'track', + deezerId, + ); + final trackData = unwrapTrackPayload(deezerMeta); + mergeTrackData(enriched, trackData); + final deezerIsrc = (trackData['isrc'] ?? '').toString().trim(); + if (deezerIsrc.isNotEmpty) { + enriched['isrc'] = deezerIsrc; + } + } catch (_) {} + } + + if (!isCurrent()) return null; + + if (usesAutomaticProvider && needsExtended && deezerId != null) { + try { + final extended = await PlatformBridge.getDeezerExtendedMetadata( + deezerId, + ); + if (extended != null) { + enriched['genre'] = extended['genre'] ?? ''; + enriched['label'] = extended['label'] ?? ''; + enriched['copyright'] = extended['copyright'] ?? ''; + } + } catch (_) { + // Extended metadata is best-effort + } + } + + if (shouldFetchLyrics) { + await syncLyricsSettings(); + + if (!isCurrent()) return null; + + final lyricsTitle = + ((selectedBest?['name'] ?? selectedBest?['title'] ?? title) + .toString()) + .trim(); + final lyricsArtist = + ((selectedBest?['artists'] ?? selectedBest?['artist'] ?? artist) + .toString()) + .trim(); + + if (lyricsTitle.isNotEmpty && lyricsArtist.isNotEmpty) { + try { + final lyricsResult = await PlatformBridge.getLyricsLRCWithSource( + rawSpotifyId ?? '', + lyricsTitle, + lyricsArtist, + durationMs: durationMs, + ); + final lyricsText = lyricsResult['lyrics']?.toString().trim() ?? ''; + final lyricsSource = lyricsResult['source']?.toString().trim() ?? ''; + if (lyricsSource.isNotEmpty) lyricsSourceName = lyricsSource; + final instrumental = + (lyricsResult['instrumental'] as bool? ?? false) || + lyricsText == '[instrumental:true]'; + if (!instrumental && lyricsText.isNotEmpty) { + enriched['lyrics'] = lyricsText; + } + } catch (e) { + _log.w('Lyrics autofill failed: $e'); + } + } + } + + if (!isCurrent()) return null; + + final availableValues = {}; + for (final entry in enriched.entries) { + final value = entry.value.trim(); + final isExplicitValue = entry.key == 'explicit'; + if (value.isNotEmpty && + (isExplicitValue || value != '0') && + value != 'null') { + availableValues[entry.key] = value; + } + } + final artworkUrl = selectedBest == null ? null : coverUrl(selectedBest); + + return MetadataAutofillResult( + values: availableValues, + coverUrl: artworkUrl, + sourceId: providerId?.isNotEmpty == true + ? providerId! + : selectedBest?['provider_id']?.toString().trim() ?? '', + lyricsSource: selectedBest == null ? lyricsSourceName : null, + ); + } + + List orderedProviders(ExtensionState state) { + final providersById = { + for (final extension in state.extensions) + if (extension.enabled && extension.hasMetadataProvider) + extension.id: extension, + }; + final ordered = []; + for (final id in state.metadataProviderPriority) { + final extension = providersById.remove(id); + if (extension != null) ordered.add(extension); + } + final remaining = providersById.values.toList() + ..sort( + (a, b) => + a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), + ); + return [...ordered, ...remaining]; + } + + String normalizeText(String value) { + final collapsed = value + .toLowerCase() + .replaceAll(_metadataCollapsePattern, ' ') + .trim(); + return collapsed.replaceAll(_metadataWhitespacePattern, ' '); + } + + bool looksLikeIsrc(String value) { + return _isrcPattern.hasMatch(value.trim().toUpperCase()); + } + + String? spotifyTrackIdFromValue(Object? value) { + final raw = value?.toString().trim() ?? ''; + if (raw.isEmpty) return null; + + if (_spotifyTrackIdPattern.hasMatch(raw)) { + return raw; + } + + if (raw.startsWith('spotify:')) { + final parts = raw.split(':'); + final last = parts.isNotEmpty ? parts.last.trim() : ''; + if (_spotifyTrackIdPattern.hasMatch(last)) { + return last; + } + return null; + } + + final uri = Uri.tryParse(raw); + if (uri != null && + uri.host.contains('spotify.com') && + uri.pathSegments.length >= 2 && + uri.pathSegments.first == 'track') { + final candidate = uri.pathSegments[1].trim(); + if (_spotifyTrackIdPattern.hasMatch(candidate)) { + return candidate; + } + } + + return null; + } + + String? deezerTrackIdFromValue(Object? value) { + final raw = value?.toString().trim() ?? ''; + if (raw.isEmpty) return null; + + if (_deezerTrackIdPattern.hasMatch(raw)) { + return raw; + } + + if (raw.startsWith('deezer:')) { + final parts = raw.split(':'); + final last = parts.isNotEmpty ? parts.last.trim() : ''; + if (_deezerTrackIdPattern.hasMatch(last)) { + return last; + } + } + + final uri = Uri.tryParse(raw); + if (uri != null && uri.host.contains('deezer.com')) { + final trackIndex = uri.pathSegments.indexOf('track'); + if (trackIndex >= 0 && trackIndex + 1 < uri.pathSegments.length) { + final candidate = uri.pathSegments[trackIndex + 1].trim(); + if (_deezerTrackIdPattern.hasMatch(candidate)) { + return candidate; + } + } + } + + return null; + } + + String? spotifyTrackId(Map track) { + for (final candidate in [track['spotify_id'], track['id']]) { + final spotifyId = spotifyTrackIdFromValue(candidate); + if (spotifyId != null) return spotifyId; + } + + final externalLinks = track['external_links']; + if (externalLinks is Map) { + final spotifyId = spotifyTrackIdFromValue(externalLinks['spotify']); + if (spotifyId != null) return spotifyId; + } + + return null; + } + + String? deezerTrackId(Map track) { + for (final candidate in [ + track['deezer_id'], + track['spotify_id'], + track['id'], + ]) { + final deezerId = deezerTrackIdFromValue(candidate); + if (deezerId != null) return deezerId; + } + + final externalLinks = track['external_links']; + if (externalLinks is Map) { + final deezerId = deezerTrackIdFromValue(externalLinks['deezer']); + if (deezerId != null) return deezerId; + } + + return null; + } + + Map unwrapTrackPayload(Map payload) { + final track = payload['track']; + if (track is Map) { + return track; + } + return payload; + } + + void mergeTrackData( + Map enriched, + Map track, + ) { + void put(String key, Object? value) { + final text = value?.toString().trim() ?? ''; + if (text.isNotEmpty && text != 'null') { + enriched[key] = text; + } + } + + put('title', track['name'] ?? track['title']); + put('artist', track['artists'] ?? track['artist']); + put('album', track['album_name'] ?? track['album']); + put('album_artist', track['album_artist']); + put('date', track['release_date']); + put('track_number', track['track_number']); + put('total_tracks', track['total_tracks']); + put('disc_number', track['disc_number']); + put('total_discs', track['total_discs']); + put('isrc', track['isrc']); + put('genre', track['genre']); + put('label', track['label']); + put('copyright', track['copyright']); + put('composer', track['composer']); + put('comment', track['comment']); + put('album_type', track['album_type'] ?? track['albumType']); + put('explicit', track['explicit'] ?? track['is_explicit']); + put('upc', track['upc'] ?? track['barcode']); + } + + int matchScore( + Map track, { + required String currentTitle, + required String currentArtist, + required String currentAlbum, + required String currentIsrc, + }) { + int textScore(String current, Object? value, int exact, int partial) { + final candidate = normalizeText((value ?? '').toString()); + if (current.isEmpty || candidate.isEmpty) return 0; + if (candidate == current) return exact; + return _metadataTextMatches(current, candidate) ? partial : 0; + } + + final candidateIsrc = track['isrc']?.toString().trim().toUpperCase() ?? ''; + return (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc + ? 10000 + : 0) + + textScore(currentTitle, track['name'] ?? track['title'], 400, 180) + + textScore( + currentArtist, + track['artists'] ?? track['artist'], + 320, + 140, + ) + + textScore(currentAlbum, track['album_name'] ?? track['album'], 120, 50); + } + + bool _metadataTextMatches(String current, String candidate) { + if (current.isEmpty || candidate.isEmpty) return false; + return current == candidate || + candidate.contains(current) || + current.contains(candidate); + } + + bool matchIsConfident( + Map track, { + required String currentTitle, + required String currentArtist, + required String currentAlbum, + required String currentIsrc, + }) { + final candidateIsrc = (track['isrc']?.toString() ?? '') + .trim() + .toUpperCase(); + if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) { + return true; + } + + final candidateTitle = normalizeText( + (track['name'] ?? track['title'] ?? '').toString(), + ); + final candidateArtist = normalizeText( + (track['artists'] ?? track['artist'] ?? '').toString(), + ); + final candidateAlbum = normalizeText( + (track['album_name'] ?? track['album'] ?? '').toString(), + ); + + final titleMatches = _metadataTextMatches(currentTitle, candidateTitle); + final artistMatches = _metadataTextMatches(currentArtist, candidateArtist); + final albumMatches = _metadataTextMatches(currentAlbum, candidateAlbum); + + if (currentTitle.isNotEmpty && currentArtist.isNotEmpty) { + return titleMatches && artistMatches; + } + if (currentTitle.isNotEmpty && currentAlbum.isNotEmpty) { + return titleMatches && albumMatches; + } + if (currentTitle.isNotEmpty) { + return titleMatches; + } + if (currentAlbum.isNotEmpty) { + return albumMatches; + } + + return false; + } + + String? coverUrl(Map track) { + String? fromValue(Object? value) { + if (value is String) { + final normalized = value.trim(); + return normalized.isEmpty ? null : normalized; + } + if (value is List) { + for (final item in value) { + final resolved = fromValue(item); + if (resolved != null) return resolved; + } + } + if (value is Map) { + for (final key in const ['url', 'original', 'large', 'src']) { + final resolved = fromValue(value[key]); + if (resolved != null) return resolved; + } + } + return null; + } + + return fromValue(track['cover_url']) ?? fromValue(track['images']); + } +} diff --git a/lib/services/metadata_cover_resources.dart b/lib/services/metadata_cover_resources.dart new file mode 100644 index 00000000..ec6ee1cd --- /dev/null +++ b/lib/services/metadata_cover_resources.dart @@ -0,0 +1,175 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +typedef MetadataCoverDetails = ({int width, int height, int bytes}); + +class MetadataCoverPreview { + final String path; + final String tempDir; + final MetadataCoverDetails? details; + + const MetadataCoverPreview({ + required this.path, + required this.tempDir, + this.details, + }); +} + +/// Owns editor artwork, including resources created after a sheet is closed. +/// A save lease keeps every owned file alive until the writer has finished. +class MetadataCoverResources { + static final _log = AppLogger('MetadataCoverResources'); + final Set _directories = {}; + final Set _deferredReleases = {}; + bool _disposed = false; + int _leases = 0; + int _pending = 0; + + Future Function() retain() { + assert(!_disposed); + _leases++; + var released = false; + return () async { + if (released) return; + released = true; + _leases--; + await _cleanupIfIdle(); + }; + } + + Future dispose() async { + _disposed = true; + await _cleanupIfIdle(); + } + + Future _cleanupIfIdle() async { + if (_leases != 0) return; + final directories = _disposed && _pending == 0 + ? _directories.toList() + : _deferredReleases.toList(); + await Future.wait(directories.map(release)); + } + + Future release(String? directory) async { + if (directory == null || !_directories.contains(directory)) return; + if (_leases != 0) { + _deferredReleases.add(directory); + return; + } + _directories.remove(directory); + _deferredReleases.remove(directory); + try { + final dir = Directory(directory); + if (await dir.exists()) await dir.delete(recursive: true); + } catch (_) {} + } + + Future readDetails(String path) async { + try { + final dimensions = await FFmpegService.probeImageDimensions(path); + if (dimensions == null) return null; + return ( + width: dimensions.width, + height: dimensions.height, + bytes: await File(path).length(), + ); + } catch (_) { + return null; + } + } + + Future _create( + String prefix, + String filename, + Future Function(String path) write, { + bool probe = true, + }) async { + _pending++; + String? directory; + try { + directory = (await Directory.systemTemp.createTemp(prefix)).path; + _directories.add(directory); + final path = '$directory${Platform.pathSeparator}$filename'; + await write(path); + final file = File(path); + if (!await file.exists() || await file.length() == 0) { + throw StateError('Artwork file is empty or inaccessible'); + } + return MetadataCoverPreview( + path: path, + tempDir: directory, + details: probe ? await readDetails(path) : null, + ); + } catch (_) { + await release(directory); + rethrow; + } finally { + _pending--; + await _cleanupIfIdle(); + } + } + + Future download(String url) async { + try { + return await _create('metadata_cover_', 'cover.jpg', (path) async { + final result = await PlatformBridge.downloadCoverToFile(url, path); + if (result['error'] != null || result['success'] == false) { + throw StateError( + result['error']?.toString() ?? 'Cover download failed', + ); + } + }); + } catch (error) { + _log.w('Could not download metadata artwork: $error'); + return null; + } + } + + Future extract(String filePath) async { + try { + return await _create('metadata_embedded_cover_', 'cover.jpg', ( + path, + ) async { + final result = await PlatformBridge.extractCoverToFile(filePath, path); + if (result['error'] != null) { + throw StateError(result['error'].toString()); + } + }); + } catch (_) { + return null; + } + } + + Future copyPicked({ + required String extension, + String? sourcePath, + Uint8List? bytes, + }) { + return _create('metadata_selected_cover_', 'cover.$extension', ( + path, + ) async { + if (sourcePath?.isNotEmpty == true) { + await File(sourcePath!).copy(path); + } else if (bytes?.isNotEmpty == true) { + await File(path).writeAsBytes(bytes!, flush: true); + } else { + throw StateError('Unable to read selected image'); + } + }, probe: false); + } + + Future resize(String source, int maxDimension) { + return _create('metadata_resized_cover_', 'cover.jpg', (path) async { + final resized = await FFmpegService.resizeCoverArt( + inputPath: source, + outputPath: path, + maxDimension: maxDimension, + ); + if (!resized) throw StateError('Cover resize failed'); + }, probe: false); + } +} diff --git a/lib/widgets/metadata_candidate_tile.dart b/lib/widgets/metadata_candidate_tile.dart new file mode 100644 index 00000000..26fdbe26 --- /dev/null +++ b/lib/widgets/metadata_candidate_tile.dart @@ -0,0 +1,164 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/services/metadata_autofill_service.dart'; +import 'package:spotiflac_android/services/metadata_cover_resources.dart'; +import 'package:spotiflac_android/utils/isrc_utils.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; + +class MetadataCandidateTile extends StatelessWidget { + final Map candidate; + final VoidCallback onTap; + + const MetadataCandidateTile({ + super.key, + required this.candidate, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final title = (candidate['name'] ?? candidate['title'] ?? '') + .toString() + .trim(); + final artists = (candidate['artists'] ?? candidate['artist'] ?? '') + .toString() + .trim(); + final album = (candidate['album_name'] ?? candidate['album'] ?? '') + .toString() + .trim(); + final date = candidate['release_date']?.toString().trim() ?? ''; + final isrc = candidate['isrc']?.toString().trim() ?? ''; + final subtitle = [ + artists, + album, + if (date.isNotEmpty) date, + if (isrc.isNotEmpty) 'ISRC ${formatIsrcForDisplay(isrc)}', + ].where((value) => value.isNotEmpty).join('\n'); + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + leading: _MetadataCandidateArtwork( + coverUrl: MetadataAutofillService().coverUrl(candidate), + ), + title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: subtitle.isEmpty + ? null + : Text(subtitle, maxLines: 4, overflow: TextOverflow.ellipsis), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ); + } +} + +class _MetadataCandidateArtwork extends StatefulWidget { + final String? coverUrl; + + const _MetadataCandidateArtwork({required this.coverUrl}); + + @override + State<_MetadataCandidateArtwork> createState() => + _MetadataCandidateArtworkState(); +} + +class _MetadataCandidateArtworkState extends State<_MetadataCandidateArtwork> { + final _covers = MetadataCoverResources(); + String? _coverPath; + String? _tempDir; + ({int width, int height, int bytes})? _details; + int _generation = 0; + + @override + void initState() { + super.initState(); + if (widget.coverUrl?.isNotEmpty == true) { + unawaited(_loadCover()); + } + } + + Future _loadCover() async { + final generation = ++_generation; + final preview = await _covers.download(widget.coverUrl!); + if (!mounted || generation != _generation) { + await _covers.release(preview?.tempDir); + return; + } + setState(() { + _coverPath = preview?.path; + _tempDir = preview?.tempDir; + _details = preview?.details; + }); + } + + @override + void didUpdateWidget(covariant _MetadataCandidateArtwork oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.coverUrl == oldWidget.coverUrl) return; + _generation++; + unawaited(_covers.release(_tempDir)); + _tempDir = null; + _coverPath = null; + _details = null; + if (widget.coverUrl?.isNotEmpty == true) unawaited(_loadCover()); + } + + @override + void dispose() { + unawaited(_covers.dispose()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final coverUrl = widget.coverUrl; + final details = _details; + final image = _coverPath != null + ? Image.file( + File(_coverPath!), + width: 56, + height: 56, + fit: BoxFit.cover, + cacheWidth: (56 * MediaQuery.devicePixelRatioOf(context)).round(), + cacheHeight: (56 * MediaQuery.devicePixelRatioOf(context)).round(), + filterQuality: FilterQuality.low, + ) + : coverUrl != null + ? CachedCoverImage( + imageUrl: coverUrl, + width: 56, + height: 56, + fit: BoxFit.cover, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: const Icon(Icons.music_note), + ); + + return SizedBox( + width: 72, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect(borderRadius: BorderRadius.circular(10), child: image), + if (details != null) ...[ + const SizedBox(height: 3), + Text( + '${details.width}×${details.height} · ' + '${(details.bytes / 1024).toStringAsFixed(1)} KB', + maxLines: 2, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontSize: 9, + height: 1.1, + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/metadata_text_field.dart b/lib/widgets/metadata_text_field.dart new file mode 100644 index 00000000..cc684b55 --- /dev/null +++ b/lib/widgets/metadata_text_field.dart @@ -0,0 +1,102 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +Color metadataEditorFieldFill(BuildContext context, ColorScheme cs) { + if (context.isMornye) return Colors.transparent; + final isDark = Theme.of(context).brightness == Brightness.dark; + return isDark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), cs.surface) + : cs.surface; +} + +class MetadataTextField extends StatelessWidget { + final ColorScheme colorScheme; + final String label; + final TextEditingController controller; + final String? hint; + final TextInputType? keyboard; + final int maxLines; + final List? inputFormatters; + + const MetadataTextField( + this.colorScheme, + this.label, + this.controller, { + super.key, + this.hint, + this.keyboard, + this.maxLines = 1, + this.inputFormatters, + }); + + @override + Widget build(BuildContext context) { + final cs = colorScheme; + final radius = BorderRadius.circular(14); + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + ), + ), + ), + if (context.isMornye) ...[ + CupertinoTextField( + controller: controller, + keyboardType: keyboard, + inputFormatters: inputFormatters, + maxLines: maxLines, + placeholder: hint, + cursorColor: cs.primary, + style: Theme.of(context).textTheme.bodyLarge, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14), + decoration: null, + ), + const Divider(height: 0.5), + ] else + TextField( + controller: controller, + keyboardType: keyboard, + inputFormatters: inputFormatters, + maxLines: maxLines, + cursorColor: cs.primary, + style: Theme.of(context).textTheme.bodyLarge, + decoration: InputDecoration( + hintText: hint, + filled: true, + fillColor: metadataEditorFieldFill(context, cs), + isDense: true, + border: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: cs.primary, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 15, + ), + ), + ), + ], + ), + ); + } +} diff --git a/test/metadata_autofill_service_test.dart b/test/metadata_autofill_service_test.dart new file mode 100644 index 00000000..148cf2be --- /dev/null +++ b/test/metadata_autofill_service_test.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/services/metadata_autofill_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final service = MetadataAutofillService(); + + Extension provider(String id, String name, {bool enabled = true}) => + Extension( + id: id, + name: id, + displayName: name, + version: '1.0.0', + description: 'Metadata fixture', + enabled: enabled, + status: 'loaded', + hasMetadataProvider: true, + ); + + test( + 'configured providers precede remaining enabled alphabetical providers', + () { + final ordered = service.orderedProviders( + ExtensionState( + extensions: [ + provider('third', 'Zeta'), + provider('second', 'Alpha'), + provider('first', 'Beta'), + provider('disabled', 'Disabled', enabled: false), + ], + metadataProviderPriority: const ['missing', 'third', 'third'], + ), + ); + expect(ordered.map((extension) => extension.id), [ + 'third', + 'second', + 'first', + ]); + }, + ); + + test('Unicode title/artist and exact ISRC matches retain confidence', () { + expect(service.normalizeText(' 春の歌 — 歌手 '), '春の歌 歌手'); + expect( + service.matchIsConfident( + {'name': '春の歌', 'artists': '歌手'}, + currentTitle: '春の歌', + currentArtist: '歌手', + currentAlbum: '', + currentIsrc: '', + ), + isTrue, + ); + expect( + service.matchIsConfident( + { + 'name': 'Other Song', + 'artists': 'Other Artist', + 'isrc': 'USABC1234567', + }, + currentTitle: 'song', + currentArtist: 'artist', + currentAlbum: '', + currentIsrc: 'USABC1234567', + ), + isTrue, + ); + expect( + service.matchIsConfident( + {'name': 'Song', 'artists': 'Another Performer'}, + currentTitle: 'song', + currentArtist: 'artist', + currentAlbum: '', + currentIsrc: '', + ), + isFalse, + ); + }); + + test( + 'selected-provider enrichment filters unknown counts but keeps explicit zero', + () async { + final result = await service.enrich( + { + 'title': 'Song', + 'track_number': 0, + 'explicit': 0, + 'images': [ + {'large': 'https://example.invalid/cover.jpg'}, + ], + }, + currentValues: const {}, + fields: {'title', 'explicit', 'cover'}, + providerId: 'example-provider', + sourceTrackId: null, + durationMs: 0, + syncLyricsSettings: () async {}, + isCurrent: () => true, + ); + expect(result!.values, {'title': 'Song', 'explicit': '0'}); + expect(result.coverUrl, 'https://example.invalid/cover.jpg'); + expect(result.sourceId, 'example-provider'); + }, + ); + + test('cancelled enrichment performs no lyrics work', () async { + var synced = false; + final result = await service.enrich( + null, + currentValues: const {'title': 'Song', 'artist': 'Artist'}, + fields: {'lyrics'}, + providerId: null, + sourceTrackId: null, + durationMs: 0, + syncLyricsSettings: () async { + synced = true; + }, + isCurrent: () => false, + ); + expect(result, isNull); + expect(synced, isFalse); + }); + + for (final instrumental in [false, true]) { + test('lyrics-only enrichment handles instrumental=$instrumental', () async { + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + var synced = false; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'getLyricsLRCWithSource'); + expect(synced, isTrue); + return jsonEncode({ + 'lyrics': instrumental ? '[instrumental:true]' : '[00:01.00]Lyric', + 'instrumental': instrumental, + 'source': 'Example Lyrics', + }); + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final result = await service.enrich( + null, + currentValues: const {'title': 'Song', 'artist': 'Artist'}, + fields: {'lyrics'}, + providerId: null, + sourceTrackId: null, + durationMs: 180000, + syncLyricsSettings: () async { + synced = true; + }, + isCurrent: () => true, + ); + expect(result!.lyricsSource, 'Example Lyrics'); + expect(result.values.containsKey('lyrics'), !instrumental); + }); + } +} diff --git a/test/metadata_cover_resources_test.dart b/test/metadata_cover_resources_test.dart new file mode 100644 index 00000000..18b50fa0 --- /dev/null +++ b/test/metadata_cover_resources_test.dart @@ -0,0 +1,77 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/metadata_cover_resources.dart'; + +void main() { + test( + 'closing an editor preserves artwork until its save lease finishes', + () async { + final resources = MetadataCoverResources(); + final preview = await resources.copyPicked( + extension: 'png', + bytes: Uint8List.fromList([1, 2, 3]), + ); + final release = resources.retain(); + await resources.dispose(); + expect(await File(preview.path).readAsBytes(), [1, 2, 3]); + await release(); + expect(await Directory(preview.tempDir).exists(), isFalse); + await release(); + }, + ); + + test( + 'disposal during a file creation cleans the completed resource', + () async { + final resources = MetadataCoverResources(); + final pending = resources.copyPicked( + extension: 'jpg', + bytes: Uint8List.fromList([1, 2, 3]), + ); + await resources.dispose(); + final preview = await pending; + expect(await Directory(preview.tempDir).exists(), isFalse); + }, + ); + + test( + 'replacing artwork defers release until every save lease finishes', + () async { + final resources = MetadataCoverResources(); + addTearDown(resources.dispose); + final preview = await resources.copyPicked( + extension: 'png', + bytes: Uint8List.fromList([1, 2, 3]), + ); + final firstSave = resources.retain(); + final secondSave = resources.retain(); + await resources.release(preview.tempDir); + expect(await File(preview.path).readAsBytes(), [1, 2, 3]); + await firstSave(); + expect(await File(preview.path).exists(), isTrue); + await secondSave(); + expect(await Directory(preview.tempDir).exists(), isFalse); + }, + ); + + test( + 'releasing a selected image leaves other editor resources intact', + () async { + final resources = MetadataCoverResources(); + addTearDown(resources.dispose); + final first = await resources.copyPicked( + extension: 'png', + bytes: Uint8List.fromList([1]), + ); + final second = await resources.copyPicked( + extension: 'jpg', + bytes: Uint8List.fromList([2]), + ); + await resources.release(first.tempDir); + expect(await Directory(first.tempDir).exists(), isFalse); + expect(await File(second.path).exists(), isTrue); + }, + ); +} diff --git a/test/metadata_editor_controller_test.dart b/test/metadata_editor_controller_test.dart new file mode 100644 index 00000000..b4b40402 --- /dev/null +++ b/test/metadata_editor_controller_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/controllers/metadata_editor_controller.dart'; + +void main() { + test('save snapshots preserve empty tags and explicit track/disc totals', () { + final editor = MetadataEditorController({ + 'title': 'Song', + 'artist': 'Artist One; Artist Two', + 'total_tracks': '12', + 'total_discs': '2', + 'album_type': ' Compilation ', + 'explicit': 'true', + }); + final tags = editor.saveMetadata(artistTagMode: 'all'); + editor['title']!.text = 'Changed'; + editor.dispose(); + + expect(tags['title'], 'Song'); + expect(tags['artist'], 'Artist One; Artist Two'); + expect(tags['lyrics'], ''); + expect(tags['track_total'], '12'); + expect(tags['disc_total'], '2'); + expect(tags['compilation'], '1'); + expect(tags['explicit'], '1'); + expect(tags['artist_tag_mode'], 'all'); + expect(tags.containsKey('total_tracks'), isFalse); + expect(tags.containsKey('total_discs'), isFalse); + }); + + test('edits and disposal invalidate asynchronous lookup results', () { + final editor = MetadataEditorController({'title': 'Song'}); + final first = editor.beginLookup(); + expect(editor.isCurrent(first), isTrue); + editor['title']!.text = 'Other Song'; + expect(editor.isCurrent(first), isFalse); + final second = editor.beginLookup(); + editor.dispose(); + expect(editor.isCurrent(second), isFalse); + }); + + test('caret and composition changes keep pending lookup results current', () { + final editor = MetadataEditorController({'title': 'Song'}); + addTearDown(editor.dispose); + final generation = editor.beginLookup(); + final title = editor['title']!; + title.selection = const TextSelection.collapsed(offset: 2); + expect(editor.isCurrent(generation), isTrue); + title.value = title.value.copyWith( + composing: const TextRange(start: 0, end: 4), + ); + expect(editor.isCurrent(generation), isTrue); + title.clearComposing(); + expect(editor.isCurrent(generation), isTrue); + title.text = 'Other Song'; + expect(editor.isCurrent(generation), isFalse); + }); + + test( + 'empty-field selection and apply respect manual tags and explicit false', + () { + final editor = MetadataEditorController({'title': 'Manual Title'}); + addTearDown(editor.dispose); + editor.selectEmptyFields(hasCover: true); + expect(editor.autoFillFields, isNot(contains('title'))); + expect(editor.autoFillFields, isNot(contains('cover'))); + expect(editor.autoFillFields, isNot(contains('explicit'))); + editor.autoFillFields.add('explicit'); + editor.explicit = true; + final filled = editor.apply({ + 'title': 'Online Title', + 'artist': 'Online Artist', + 'explicit': '0', + }); + expect(filled, 2); + expect(editor['title']!.text, 'Manual Title'); + expect(editor['artist']!.text, 'Online Artist'); + expect(editor.explicit, isFalse); + editor.selectEmptyFields(hasCover: false); + expect(editor.autoFillFields, contains('cover')); + }, + ); +}