diff --git a/lib/screens/track_metadata_edit_autofill.dart b/lib/screens/track_metadata_edit_autofill.dart new file mode 100644 index 00000000..7e653c2f --- /dev/null +++ b/lib/screens/track_metadata_edit_autofill.dart @@ -0,0 +1,797 @@ +part of 'track_metadata_edit_sheet.dart'; + +class _AutoFillPreview { + final Map values; + final String sourceName; + final String? coverUrl; + final String? coverPath; + final String? coverTempDir; + final ({int width, int height, int bytes})? coverDetails; + + const _AutoFillPreview({ + required this.values, + required this.sourceName, + this.coverUrl, + this.coverPath, + this.coverTempDir, + this.coverDetails, + }); +} + +extension _EditMetadataSheetAutoFill on _EditMetadataSheetState { + String _metadataProviderName(String? providerId) { + final normalized = providerId?.trim() ?? ''; + if (normalized.isEmpty) { + return context.l10n.editMetadataAutoFillSourceAutomatic; + } + final extensionState = ProviderScope.containerOf( + context, + listen: false, + ).read(extensionProvider); + return extensionState.extensions + .where((extension) => extension.id == normalized) + .firstOrNull + ?.displayName ?? + normalized; + } + + Future _showMetadataProviderPicker(List providers) async { + final currentId = + providers.any( + (extension) => extension.id == _selectedMetadataProviderId, + ) + ? _selectedMetadataProviderId! + : ''; + final selectedId = + await _showEditorOptionPicker<({String id, String name})>( + title: (sheetContext) => sheetContext.l10n.editMetadataAutoFillSource, + options: (sheetContext) => [ + ( + id: '', + name: sheetContext.l10n.editMetadataAutoFillSourceAutomatic, + ), + for (final extension in providers) + (id: extension.id, name: extension.displayName), + ], + isSelected: (option) => option.id == currentId, + label: (sheetContext, option) => option.name, + icon: (sheetContext, option) => sheetContext.adaptiveIcon( + option.id.isEmpty + ? Icons.auto_awesome_outlined + : Icons.extension_outlined, + ), + ellipsizeLabels: true, + checkInSheetTheme: true, + ); + + if (!mounted || selectedId == null) return; + _setState(() { + _selectedMetadataProviderId = selectedId.id.isEmpty + ? null + : selectedId.id; + _invalidateAutoFillPreview(); + }); + } + + void _invalidateAutoFillPreview() { + _form.invalidateLookup(); + final preview = _autoFillPreview; + _autoFillPreview = null; + final coverPath = preview?.coverPath; + if (coverPath != null) { + _coverDetails.remove(coverPath); + _coverDetailLoads.remove(coverPath); + } + final tempDir = preview?.coverTempDir; + if (tempDir != null && tempDir.isNotEmpty) { + unawaited(_covers.release(tempDir)); + } + } + + void _selectAllFields() { + _setState(() { + _invalidateAutoFillPreview(); + _form.autoFillFields.addAll(MetadataEditorController.fieldKeys); + }); + } + + void _selectEmptyFields() { + _setState(() { + _invalidateAutoFillPreview(); + _form.selectEmptyFields( + hasCover: _hasValue(_currentCoverPath) || _hasValue(_selectedCoverPath), + ); + }); + } + + void _selectNoFields() { + _setState(() { + _invalidateAutoFillPreview(); + _form.autoFillFields.clear(); + }); + } + + Future>> _searchAutoFillCandidates({ + required String query, + required bool usesAutomaticProvider, + required Extension? selectedProvider, + required ExtensionState extensionState, + bool allowVerificationRetry = true, + }) async { + try { + return await _autofill.search(query, provider: selectedProvider); + } catch (error) { + if (!allowVerificationRetry || !isExtensionVerificationRequired(error)) { + rethrow; + } + + final extensionId = usesAutomaticProvider + ? extensionIdFromVerificationError( + error, + extensionState.extensions.map((extension) => extension.id), + ) + : selectedProvider?.id; + if (extensionId == null || extensionId.isEmpty) rethrow; + + _log.i( + 'Metadata autofill requires verification; waiting for $extensionId', + ); + final verified = await openVerificationAndAwaitGrant( + extensionId, + browserMode: ProviderScope.containerOf( + context, + listen: false, + ).read(settingsProvider).extensionVerificationBrowserMode, + ); + if (!verified || !mounted) rethrow; + + return _searchAutoFillCandidates( + query: query, + usesAutomaticProvider: usesAutomaticProvider, + selectedProvider: selectedProvider, + extensionState: extensionState, + allowVerificationRetry: false, + ); + } + } + + Future?> _showAutoFillCandidatePicker({ + required List> candidates, + required String sourceName, + required String currentTitle, + required String currentArtist, + }) { + return showAppBottomSheet>( + context: context, + useRootNavigator: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, + title: context.l10n.editMetadataAutoFillSource, + subtitle: [ + currentTitle, + currentArtist, + sourceName, + ].where((value) => value.trim().isNotEmpty).join(' · '), + maxHeightFactor: 0.82, + builder: (sheetContext) => ListView( + padding: const EdgeInsets.only(bottom: 16), + children: [ + _editorOptions( + children: [ + for (var index = 0; index < candidates.length; index++) ...[ + MetadataCandidateTile( + candidate: candidates[index], + onTap: () => Navigator.pop(sheetContext, candidates[index]), + ), + if (index != candidates.length - 1) + Divider( + height: 1, + indent: 88, + endIndent: 16, + color: Theme.of( + sheetContext, + ).colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ], + ), + ], + ), + ); + } + + Future _fetchAutoFillPreview() async { + if (_fetching || _saving) return; + if (_form.autoFillFields.isEmpty) { + _showSheetSnackBar(context.l10n.editMetadataAutoFillNoneSelected); + return; + } + + _setState(() { + _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 = 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 = currentValues['isrc']!.trim().toUpperCase(); + final configuredProviderId = _selectedMetadataProviderId?.trim(); + final extensionState = ProviderScope.containerOf( + context, + listen: false, + ).read(extensionProvider); + final selectedProvider = configuredProviderId == null + ? null + : extensionState.extensions + .where( + (extension) => + extension.id == configuredProviderId && + extension.enabled && + extension.hasMetadataProvider, + ) + .firstOrNull; + final selectedProviderId = selectedProvider?.id; + final usesAutomaticProvider = + selectedProviderId == null || selectedProviderId.isEmpty; + final needsTrackLookup = fields.any((key) => key != 'lyrics'); + Map? best; + + final queryParts = []; + if (title.isNotEmpty) queryParts.add(title); + if (searchArtist.isNotEmpty) queryParts.add(searchArtist); + if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album); + + if (needsTrackLookup && queryParts.isEmpty) { + if (mounted) { + _showSheetSnackBar(context.l10n.editMetadataAutoFillNoResults); + } + return; + } + + final normalizedTitle = _autofill.normalizeText(title); + final normalizedArtist = _autofill.normalizeText(searchArtist); + final normalizedAlbum = _autofill.normalizeText(album); + + if (needsTrackLookup) { + final query = queryParts.join(' '); + final results = await _searchAutoFillCandidates( + query: query, + usesAutomaticProvider: usesAutomaticProvider, + selectedProvider: selectedProvider, + extensionState: extensionState, + ); + + if (!mounted || !isCurrent()) return; + + if (results.isEmpty) { + _showSheetSnackBar(context.l10n.editMetadataAutoFillNoResults); + return; + } + + final rankedResults = [...results] + ..sort((a, b) { + final aScore = _autofill.matchScore( + a, + currentTitle: normalizedTitle, + currentArtist: normalizedArtist, + currentAlbum: normalizedAlbum, + currentIsrc: currentIsrc, + ); + final bScore = _autofill.matchScore( + b, + currentTitle: normalizedTitle, + currentArtist: normalizedArtist, + currentAlbum: normalizedAlbum, + currentIsrc: currentIsrc, + ); + return bScore.compareTo(aScore); + }); + + if (usesAutomaticProvider) { + best = rankedResults.first; + if (!_autofill.matchIsConfident( + best, + currentTitle: normalizedTitle, + currentArtist: normalizedArtist, + currentAlbum: normalizedAlbum, + currentIsrc: currentIsrc, + )) { + best = null; + } + } else { + best = await _showAutoFillCandidatePicker( + candidates: rankedResults, + sourceName: _metadataProviderName(selectedProviderId), + currentTitle: title, + currentArtist: searchArtist, + ); + if (!mounted || !isCurrent() || best == null) return; + } + + if (best == null) { + _showSheetSnackBar(context.l10n.editMetadataAutoFillNoResults); + return; + } + + best = await _autofill.resolveDetails( + best, + providerId: usesAutomaticProvider + ? best['provider_id']?.toString().trim() ?? '' + : selectedProviderId, + includeCover: fields.contains('cover'), + ); + } + + final selectedBest = best; + if (needsTrackLookup && selectedBest == null) { + throw StateError('No metadata match resolved for auto-fill'); + } + + 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), + ); + if (!hasSelectedValue) { + _showSheetSnackBar(context.l10n.editMetadataAutoFillNoResults); + return; + } + + final coverPreview = + _form.autoFillFields.contains('cover') && + coverUrl != null && + coverUrl.isNotEmpty + ? await _covers.download(coverUrl) + : null; + if (!mounted || !isCurrent()) { + if (coverPreview != null) { + await _covers.release(coverPreview.tempDir); + } + return; + } + + final resolvedSourceName = result.lyricsSource?.isNotEmpty == true + ? result.lyricsSource! + : _metadataProviderName(result.sourceId); + _setState(() { + _autoFillPreview = _AutoFillPreview( + values: availableValues, + sourceName: resolvedSourceName, + coverUrl: coverUrl?.isNotEmpty == true ? coverUrl : null, + coverPath: coverPreview?.path, + coverTempDir: coverPreview?.tempDir, + coverDetails: coverPreview?.details, + ); + if (coverPreview?.details case final details?) { + _coverDetails[coverPreview!.path] = details; + } + }); + } catch (e, stackTrace) { + _log.e('Metadata auto-fill failed: $e', e, stackTrace); + if (mounted && isCurrent()) { + _showSheetSnackBar( + context.l10n.snackbarError(context.friendlyError(e)), + ); + } + } finally { + if (mounted) _setState(() => _fetching = false); + } + } + + Future _applyAutoFillPreview() async { + final preview = _autoFillPreview; + if (preview == null || _saving || _fetching) return; + + _setState(() => _fetching = true); + try { + var filledCount = _form.apply(preview.values); + var coverDownloadFailed = false; + + 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 _covers.download(preview.coverUrl!); + coverPath = downloaded?.path; + coverTempDir = downloaded?.tempDir; + coverDetails = downloaded?.details; + } + if (coverPath != null && coverTempDir != null) { + await _cleanupSelectedCoverTemp(); + if (mounted) { + _selectedCoverPath = coverPath; + _selectedCoverTempDir = coverTempDir; + _selectedCoverName = EditMetadataSheet._onlineCoverSentinel; + if (coverDetails != null) { + _coverDetails[coverPath] = coverDetails; + } else { + unawaited(_loadCoverDetails(coverPath)); + } + filledCount++; + } + } else { + coverDownloadFailed = true; + } + } + + if (!mounted) return; + _setState(() { + if (filledCount > 0) { + final previewCoverWasTransferred = + preview.coverPath != null && + preview.coverPath == _selectedCoverPath; + if (previewCoverWasTransferred) { + _autoFillPreview = null; + } else { + _invalidateAutoFillPreview(); + } + } + }); + _showSheetSnackBar( + coverDownloadFailed + ? context.l10n.updateDownloadFailed + : filledCount > 0 + ? context.l10n.editMetadataAutoFillDoneFromSource( + filledCount, + preview.sourceName, + ) + : context.l10n.editMetadataAutoFillNoResults, + ); + } catch (e) { + if (mounted) { + _showSheetSnackBar( + context.l10n.snackbarError(context.friendlyError(e)), + ); + } + } finally { + if (mounted) _setState(() => _fetching = false); + } + } + + Widget _buildAutoFillSection(ColorScheme cs) { + return _sectionCard( + icon: Icons.travel_explore, + title: context.l10n.editMetadataAutoFill, + onHeaderTap: () => _setState(() => _showAutoFill = !_showAutoFill), + expanded: _showAutoFill, + children: [ + if (_showAutoFill) ...[ + Text( + context.l10n.editMetadataAutoFillDesc, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + const SizedBox(height: 12), + Consumer( + builder: (context, ref, _) { + final extensionState = ref.watch(extensionProvider); + final providers = _autofill.orderedProviders(extensionState); + final selectedProviderIsAvailable = providers.any( + (extension) => extension.id == _selectedMetadataProviderId, + ); + final selectedId = selectedProviderIsAvailable + ? _selectedMetadataProviderId + : null; + return _selectionField( + label: context.l10n.editMetadataAutoFillSource, + value: _metadataProviderName(selectedId), + enabled: !_fetching && !_saving, + onTap: () => _showMetadataProviderPicker(providers), + ); + }, + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _quickSelectButton( + label: context.l10n.editMetadataSelectAll, + onTap: _selectAllFields, + cs: cs, + ), + _quickSelectButton( + label: context.l10n.editMetadataSelectEmpty, + onTap: _selectEmptyFields, + cs: cs, + ), + _quickSelectButton( + label: context.l10n.editMetadataSelectNone, + onTap: _selectNoFields, + cs: cs, + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: MetadataEditorController.fieldKeys.map((key) { + final selected = _form.autoFillFields.contains(key); + if (context.isMornye) { + return MornyeFilterChip( + label: _fieldLabel(key), + selected: selected, + glass: false, + icon: selected + ? CupertinoIcons.checkmark_circle_fill + : CupertinoIcons.circle, + onTap: _fetching + ? null + : () => _setState(() { + _invalidateAutoFillPreview(); + if (selected) { + _form.autoFillFields.remove(key); + } else { + _form.autoFillFields.add(key); + } + }), + ); + } + return AppChoiceChip( + label: Text(_fieldLabel(key)), + selected: selected, + onSelected: _fetching + ? null + : (val) { + _setState(() { + _invalidateAutoFillPreview(); + if (val) { + _form.autoFillFields.add(key); + } else { + _form.autoFillFields.remove(key); + } + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: AppActionButton( + glass: false, + onPressed: (_fetching || _saving || _form.autoFillFields.isEmpty) + ? null + : _fetchAutoFillPreview, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: _fetching + ? const AppLoadingIndicator( + size: 16, + strokeWidth: 2, + color: Colors.white, + ) + : const Icon(Icons.auto_fix_high), + label: Text( + _fetching + ? context.l10n.editMetadataAutoFillSearching + : context.l10n.editMetadataAutoFillFind, + ), + ), + ), + if (_autoFillPreview case final preview?) ...[ + const SizedBox(height: 12), + _buildAutoFillPreview(cs, preview), + ], + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: AppActionButton( + outlined: true, + glass: false, + tonal: true, + onPressed: (_fetching || _fetchingMusicBrainz || _saving) + ? null + : _fetchFromMusicBrainz, + style: OutlinedButton.styleFrom( + backgroundColor: cs.surfaceContainerHigh, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: _fetchingMusicBrainz + ? const AppLoadingIndicator(size: 16, strokeWidth: 2) + : const Icon(Icons.library_music_outlined), + label: Text(context.l10n.editMetadataMusicBrainzButton), + ), + ), + const SizedBox(height: 8), + ], + ], + ); + } + + Widget _buildAutoFillPreview(ColorScheme cs, _AutoFillPreview preview) { + final previewFields = _form.autoFillFields + .where((key) { + if (key == 'cover') return preview.coverUrl != null; + return preview.values.containsKey(key); + }) + .toList(growable: false); + + return Container( + width: double.infinity, + padding: context.isMornye + ? const EdgeInsets.symmetric(vertical: 12) + : const EdgeInsets.all(12), + decoration: context.isMornye + ? null + : BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.fact_check_outlined, size: 18, color: cs.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.editMetadataAutoFillPreview(preview.sourceName), + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ], + ), + const SizedBox(height: 8), + if (preview.coverPath case final coverPath?) ...[ + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.file( + File(coverPath), + width: 112, + height: 112, + fit: BoxFit.cover, + cacheWidth: (112 * MediaQuery.devicePixelRatioOf(context)) + .round(), + cacheHeight: (112 * MediaQuery.devicePixelRatioOf(context)) + .round(), + filterQuality: FilterQuality.low, + semanticLabel: + context.l10n.editMetadataAutoFillCoverAvailable, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + if (preview.coverDetails case final details?) ...[ + const SizedBox(height: 6), + Center( + child: Text( + '${details.width} × ${details.height} px · ' + '${_formatCoverBytes(details.bytes)}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: cs.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + const SizedBox(height: 10), + ], + ...previewFields.map((key) { + final details = preview.coverDetails; + final value = key == 'cover' + ? details != null + ? '${details.width} × ${details.height} px · ' + '${_formatCoverBytes(details.bytes)}' + : context.l10n.editMetadataAutoFillCoverAvailable + : preview.values[key]!; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 104, + child: Text( + _fieldLabel(key), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + value, + maxLines: key == 'lyrics' ? 3 : 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ); + }), + const SizedBox(height: 6), + SizedBox( + width: double.infinity, + child: AppActionButton( + outlined: context.isMornye, + glass: false, + onPressed: (_fetching || _saving) ? null : _applyAutoFillPreview, + icon: const Icon(Icons.check), + label: Text(context.l10n.editMetadataAutoFillApply), + ), + ), + ], + ), + ); + } + + /// Fills genre and album artist from MusicBrainz (keyed by ISRC) as + /// editable suggestions; nothing is saved until the user taps Save. + Future _fetchFromMusicBrainz() async { + 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: _form['album']!.text.trim(), + ); + if (!mounted || !_form.isCurrent(generation)) return; + var filled = 0; + if (tags.genre.isNotEmpty) { + _form['genre']!.text = tags.genre; + filled++; + } + if (tags.albumArtist.isNotEmpty) { + _form['album_artist']!.text = tags.albumArtist; + filled++; + } + _showSheetSnackBar( + filled > 0 + ? context.l10n.editMetadataMusicBrainzFilled + : context.l10n.editMetadataMusicBrainzNothing, + ); + } catch (_) { + if (mounted) { + _showSheetSnackBar(context.l10n.editMetadataMusicBrainzNothing); + } + } finally { + if (mounted) _setState(() => _fetchingMusicBrainz = false); + } + } +} diff --git a/lib/screens/track_metadata_edit_cover.dart b/lib/screens/track_metadata_edit_cover.dart new file mode 100644 index 00000000..c5e035f7 --- /dev/null +++ b/lib/screens/track_metadata_edit_cover.dart @@ -0,0 +1,347 @@ +part of 'track_metadata_edit_sheet.dart'; + +extension _EditMetadataSheetCover on _EditMetadataSheetState { + Future _showCoverResolutionPicker() async { + final currentValue = _coverMaxDimension ?? 0; + final selectedValue = await _showEditorOptionPicker( + title: (sheetContext) => sheetContext.l10n.trackCoverResolution, + options: (sheetContext) => [ + 0, + ..._EditMetadataSheetState._coverResizeDimensions, + ], + isSelected: (value) => value == currentValue, + label: (sheetContext, value) => value == 0 + ? _originalCoverResolutionLabel( + sheetContext.l10n.trackConvertOriginal, + ) + : '$value px', + icon: (sheetContext, value) => context.isMornye + ? CupertinoIcons.photo + : value == 0 + ? Icons.photo_size_select_actual_outlined + : Icons.photo_size_select_large_outlined, + ); + + if (!mounted || selectedValue == null) return; + _setState(() { + _coverMaxDimension = selectedValue == 0 ? null : selectedValue; + }); + } + + String _originalCoverResolutionLabel(String originalLabel) { + final sourcePath = _selectedCoverPath ?? _currentCoverPath; + final details = sourcePath == null ? null : _coverDetails[sourcePath]; + if (details == null) return originalLabel; + return '$originalLabel · ${details.width} × ${details.height} px · ' + '${_formatCoverBytes(details.bytes)}'; + } + + String _formatCoverBytes(int bytes) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + + Future _loadCoverDetails(String path) async { + if (_coverDetails.containsKey(path) || !_coverDetailLoads.add(path)) { + return; + } + final details = await _covers.readDetails(path); + if (!mounted) return; + _setState(() { + _coverDetailLoads.remove(path); + final isActivePath = + path == _currentCoverPath || path == _selectedCoverPath; + if (isActivePath && details != null) { + _coverDetails[path] = details; + } + }); + } + + String _resolveImageExtension(String? ext, Uint8List? bytes) { + final normalized = (ext ?? '').toLowerCase(); + if (normalized == 'png' || + normalized == 'jpg' || + normalized == 'jpeg' || + normalized == 'webp') { + return normalized == 'jpeg' ? 'jpg' : normalized; + } + if (bytes != null && bytes.length >= 8) { + if (bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return 'png'; + } + if (bytes[0] == 0xFF && bytes[1] == 0xD8) { + return 'jpg'; + } + if (bytes.length >= 12 && + bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46 && + bytes[8] == 0x57 && + bytes[9] == 0x45 && + bytes[10] == 0x42 && + bytes[11] == 0x50) { + return 'webp'; + } + } + return 'jpg'; + } + + Future _cleanupSelectedCoverTemp() async { + final directory = _selectedCoverTempDir; + final path = _selectedCoverPath; + _selectedCoverPath = null; + _selectedCoverTempDir = null; + _selectedCoverName = null; + _coverDetails.remove(path); + _coverDetailLoads.remove(path); + await _covers.release(directory); + } + + Future _loadCurrentCoverPreview() async { + if (_loadingCurrentCover) return; + _setState(() => _loadingCurrentCover = true); + final preview = await _covers.extract(widget.filePath); + if (!mounted) return; + final oldDirectory = _currentCoverTempDir; + _coverDetails.remove(_currentCoverPath); + _coverDetailLoads.remove(_currentCoverPath); + _setState(() { + _currentCoverPath = preview?.path; + _currentCoverTempDir = preview?.tempDir; + _loadingCurrentCover = false; + if (preview?.details case final details?) { + _coverDetails[preview!.path] = details; + } + }); + await _covers.release(oldDirectory); + } + + Future _pickCoverImage() async { + try { + final picked = await FilePicker.pickFile(type: FileType.image); + if (picked == null || !mounted) return; + final sourcePath = picked.path; + 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) return; + _setState(() { + _selectedCoverPath = preview.path; + _selectedCoverTempDir = preview.tempDir; + _selectedCoverName = picked.name; + }); + unawaited(_loadCoverDetails(preview.path)); + } catch (e) { + if (!mounted) return; + _showSheetSnackBar(context.l10n.snackbarError(context.friendlyError(e))); + } + } + + Widget _buildCoverEditor(ColorScheme cs) { + final hasSelectedCover = _hasValue(_selectedCoverPath); + final hasCurrentCover = _hasValue(_currentCoverPath); + return _sectionCard( + icon: Icons.image_outlined, + title: context.l10n.editMetadataFieldCover, + children: [ + if (_loadingCurrentCover) + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: AppProgressIndicator.linear(minHeight: 2), + ) + else if (!hasCurrentCover && !hasSelectedCover) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + context.l10n.trackCoverNoEmbeddedArt, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ), + Row( + children: [ + Expanded( + child: AppActionButton( + outlined: true, + glass: false, + onPressed: _saving ? null : _pickCoverImage, + icon: const Icon(Icons.image_outlined), + label: Text( + hasSelectedCover + ? context.l10n.trackCoverReplace + : context.l10n.trackCoverPick, + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + if (hasSelectedCover) ...[ + const SizedBox(width: 8), + AppIconButton( + tooltip: context.l10n.trackCoverClearSelected, + onPressed: _saving + ? null + : () async { + await _cleanupSelectedCoverTemp(); + if (!mounted) return; + _setState(() {}); + }, + icon: const Icon(Icons.close), + ), + ], + ], + ), + if (hasCurrentCover || hasSelectedCover) ...[ + const SizedBox(height: 12), + Row( + children: [ + if (hasCurrentCover) + Expanded( + child: _buildCoverPreviewTile( + cs: cs, + path: _currentCoverPath!, + label: context.l10n.trackCoverCurrent, + ), + ), + if (hasCurrentCover && hasSelectedCover) + const SizedBox(width: 12), + if (hasSelectedCover) + Expanded( + child: _buildCoverPreviewTile( + cs: cs, + path: _selectedCoverPath!, + label: + _selectedCoverName == + EditMetadataSheet._onlineCoverSentinel + ? context.l10n.trackCoverOnline + : (_selectedCoverName ?? + context.l10n.trackCoverSelected), + ), + ), + ], + ), + if (hasSelectedCover) ...[ + const SizedBox(height: 8), + Text( + context.l10n.trackCoverReplaceNotice, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ], + const SizedBox(height: 12), + _selectionField( + label: context.l10n.trackCoverResolution, + value: _coverMaxDimension == null + ? _originalCoverResolutionLabel( + context.l10n.trackConvertOriginal, + ) + : '${_coverMaxDimension!} px', + enabled: !_saving, + onTap: _showCoverResolutionPicker, + ), + Text( + context.l10n.trackCoverResolutionHint, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ], + const SizedBox(height: 8), + ], + ); + } + + Widget _buildCoverPreviewTile({ + required ColorScheme cs, + required String path, + required String label, + }) { + final details = _coverDetails[path]; + final loadingDetails = _coverDetailLoads.contains(path); + return Column( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: cs.shadow.withValues(alpha: 0.15), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + File(path), + height: 160, + width: 160, + fit: BoxFit.cover, + cacheWidth: (160 * MediaQuery.devicePixelRatioOf(context)) + .round(), + cacheHeight: (160 * MediaQuery.devicePixelRatioOf(context)) + .round(), + filterQuality: FilterQuality.low, + errorBuilder: (_, _, _) => Container( + width: 160, + height: 160, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.broken_image, + color: cs.onSurfaceVariant, + size: 32, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + label, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: cs.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 3), + if (details != null) + Text( + '${details.width} × ${details.height} px · ' + '${_formatCoverBytes(details.bytes)}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: cs.primary, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + else if (loadingDetails) + AppLoadingIndicator(size: 12, strokeWidth: 1.5, color: cs.primary), + ], + ); + } +} diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 697f9ea0..1195475c 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -35,26 +35,11 @@ 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'; +part 'track_metadata_edit_autofill.dart'; +part 'track_metadata_edit_cover.dart'; + final _log = AppLogger('MetadataEditor'); -class _AutoFillPreview { - final Map values; - final String sourceName; - final String? coverUrl; - final String? coverPath; - final String? coverTempDir; - final ({int width, int height, int bytes})? coverDetails; - - const _AutoFillPreview({ - required this.values, - required this.sourceName, - this.coverUrl, - this.coverPath, - this.coverTempDir, - this.coverDetails, - }); -} - class EditMetadataSheet extends StatefulWidget { static const _onlineCoverSentinel = '__online_cover__'; final ColorScheme colorScheme; @@ -102,83 +87,9 @@ class _EditMetadataSheetState extends State { final GlobalKey _sheetMessengerKey = GlobalKey(); - String _metadataProviderName(String? providerId) { - final normalized = providerId?.trim() ?? ''; - if (normalized.isEmpty) { - return context.l10n.editMetadataAutoFillSourceAutomatic; - } - final extensionState = ProviderScope.containerOf( - context, - listen: false, - ).read(extensionProvider); - return extensionState.extensions - .where((extension) => extension.id == normalized) - .firstOrNull - ?.displayName ?? - normalized; - } - - Future _showMetadataProviderPicker(List providers) async { - final currentId = - providers.any( - (extension) => extension.id == _selectedMetadataProviderId, - ) - ? _selectedMetadataProviderId! - : ''; - final selectedId = - await _showEditorOptionPicker<({String id, String name})>( - title: (sheetContext) => sheetContext.l10n.editMetadataAutoFillSource, - options: (sheetContext) => [ - ( - id: '', - name: sheetContext.l10n.editMetadataAutoFillSourceAutomatic, - ), - for (final extension in providers) - (id: extension.id, name: extension.displayName), - ], - isSelected: (option) => option.id == currentId, - label: (sheetContext, option) => option.name, - icon: (sheetContext, option) => sheetContext.adaptiveIcon( - option.id.isEmpty - ? Icons.auto_awesome_outlined - : Icons.extension_outlined, - ), - ellipsizeLabels: true, - checkInSheetTheme: true, - ); - - if (!mounted || selectedId == null) return; - setState(() { - _selectedMetadataProviderId = selectedId.id.isEmpty - ? null - : selectedId.id; - _invalidateAutoFillPreview(); - }); - } - - Future _showCoverResolutionPicker() async { - final currentValue = _coverMaxDimension ?? 0; - final selectedValue = await _showEditorOptionPicker( - title: (sheetContext) => sheetContext.l10n.trackCoverResolution, - options: (sheetContext) => [0, ..._coverResizeDimensions], - isSelected: (value) => value == currentValue, - label: (sheetContext, value) => value == 0 - ? _originalCoverResolutionLabel( - sheetContext.l10n.trackConvertOriginal, - ) - : '$value px', - icon: (sheetContext, value) => context.isMornye - ? CupertinoIcons.photo - : value == 0 - ? Icons.photo_size_select_actual_outlined - : Icons.photo_size_select_large_outlined, - ); - - if (!mounted || selectedValue == null) return; - setState(() { - _coverMaxDimension = selectedValue == 0 ? null : selectedValue; - }); - } + /// setState is @protected, so the extension part files route rebuilds + /// through this forwarder. + void _setState(VoidCallback fn) => setState(fn); Future _showEditorOptionPicker({ required String Function(BuildContext sheetContext) title, @@ -284,21 +195,6 @@ class _EditMetadataSheetState extends State { ); } - void _invalidateAutoFillPreview() { - _form.invalidateLookup(); - final preview = _autoFillPreview; - _autoFillPreview = null; - final coverPath = preview?.coverPath; - if (coverPath != null) { - _coverDetails.remove(coverPath); - _coverDetailLoads.remove(coverPath); - } - final tempDir = preview?.coverTempDir; - if (tempDir != null && tempDir.isNotEmpty) { - unawaited(_covers.release(tempDir)); - } - } - void _showSheetSnackBar(String message) { final snackBar = SnackBar(content: Text(message)); final messenger = _sheetMessengerKey.currentState; @@ -316,128 +212,6 @@ class _EditMetadataSheetState extends State { bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; - String _originalCoverResolutionLabel(String originalLabel) { - final sourcePath = _selectedCoverPath ?? _currentCoverPath; - final details = sourcePath == null ? null : _coverDetails[sourcePath]; - if (details == null) return originalLabel; - return '$originalLabel · ${details.width} × ${details.height} px · ' - '${_formatCoverBytes(details.bytes)}'; - } - - String _formatCoverBytes(int bytes) { - return '${(bytes / 1024).toStringAsFixed(1)} KB'; - } - - Future _loadCoverDetails(String path) async { - if (_coverDetails.containsKey(path) || !_coverDetailLoads.add(path)) { - return; - } - final details = await _covers.readDetails(path); - if (!mounted) return; - setState(() { - _coverDetailLoads.remove(path); - final isActivePath = - path == _currentCoverPath || path == _selectedCoverPath; - if (isActivePath && details != null) { - _coverDetails[path] = details; - } - }); - } - - String _resolveImageExtension(String? ext, Uint8List? bytes) { - final normalized = (ext ?? '').toLowerCase(); - if (normalized == 'png' || - normalized == 'jpg' || - normalized == 'jpeg' || - normalized == 'webp') { - return normalized == 'jpeg' ? 'jpg' : normalized; - } - if (bytes != null && bytes.length >= 8) { - if (bytes[0] == 0x89 && - bytes[1] == 0x50 && - bytes[2] == 0x4E && - bytes[3] == 0x47) { - return 'png'; - } - if (bytes[0] == 0xFF && bytes[1] == 0xD8) { - return 'jpg'; - } - if (bytes.length >= 12 && - bytes[0] == 0x52 && - bytes[1] == 0x49 && - bytes[2] == 0x46 && - bytes[3] == 0x46 && - bytes[8] == 0x57 && - bytes[9] == 0x45 && - bytes[10] == 0x42 && - bytes[11] == 0x50) { - return 'webp'; - } - } - return 'jpg'; - } - - Future _cleanupSelectedCoverTemp() async { - final directory = _selectedCoverTempDir; - final path = _selectedCoverPath; - _selectedCoverPath = null; - _selectedCoverTempDir = null; - _selectedCoverName = null; - _coverDetails.remove(path); - _coverDetailLoads.remove(path); - await _covers.release(directory); - } - - Future _loadCurrentCoverPreview() async { - if (_loadingCurrentCover) return; - setState(() => _loadingCurrentCover = true); - final preview = await _covers.extract(widget.filePath); - if (!mounted) return; - final oldDirectory = _currentCoverTempDir; - _coverDetails.remove(_currentCoverPath); - _coverDetailLoads.remove(_currentCoverPath); - setState(() { - _currentCoverPath = preview?.path; - _currentCoverTempDir = preview?.tempDir; - _loadingCurrentCover = false; - if (preview?.details case final details?) { - _coverDetails[preview!.path] = details; - } - }); - await _covers.release(oldDirectory); - } - - Future _pickCoverImage() async { - try { - final picked = await FilePicker.pickFile(type: FileType.image); - if (picked == null || !mounted) return; - final sourcePath = picked.path; - 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) return; - setState(() { - _selectedCoverPath = preview.path; - _selectedCoverTempDir = preview.tempDir; - _selectedCoverName = picked.name; - }); - unawaited(_loadCoverDetails(preview.path)); - } catch (e) { - if (!mounted) return; - _showSheetSnackBar(context.l10n.snackbarError(context.friendlyError(e))); - } - } - String _fieldLabel(String key) { final l10n = context.l10n; return switch (key) { @@ -465,393 +239,6 @@ class _EditMetadataSheetState extends State { }; } - void _selectAllFields() { - setState(() { - _invalidateAutoFillPreview(); - _form.autoFillFields.addAll(MetadataEditorController.fieldKeys); - }); - } - - void _selectEmptyFields() { - setState(() { - _invalidateAutoFillPreview(); - _form.selectEmptyFields( - hasCover: _hasValue(_currentCoverPath) || _hasValue(_selectedCoverPath), - ); - }); - } - - void _selectNoFields() { - setState(() { - _invalidateAutoFillPreview(); - _form.autoFillFields.clear(); - }); - } - - Future>> _searchAutoFillCandidates({ - required String query, - required bool usesAutomaticProvider, - required Extension? selectedProvider, - required ExtensionState extensionState, - bool allowVerificationRetry = true, - }) async { - try { - return await _autofill.search(query, provider: selectedProvider); - } catch (error) { - if (!allowVerificationRetry || !isExtensionVerificationRequired(error)) { - rethrow; - } - - final extensionId = usesAutomaticProvider - ? extensionIdFromVerificationError( - error, - extensionState.extensions.map((extension) => extension.id), - ) - : selectedProvider?.id; - if (extensionId == null || extensionId.isEmpty) rethrow; - - _log.i( - 'Metadata autofill requires verification; waiting for $extensionId', - ); - final verified = await openVerificationAndAwaitGrant( - extensionId, - browserMode: ProviderScope.containerOf( - context, - listen: false, - ).read(settingsProvider).extensionVerificationBrowserMode, - ); - if (!verified || !mounted) rethrow; - - return _searchAutoFillCandidates( - query: query, - usesAutomaticProvider: usesAutomaticProvider, - selectedProvider: selectedProvider, - extensionState: extensionState, - allowVerificationRetry: false, - ); - } - } - - Future?> _showAutoFillCandidatePicker({ - required List> candidates, - required String sourceName, - required String currentTitle, - required String currentArtist, - }) { - return showAppBottomSheet>( - context: context, - useRootNavigator: true, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, - title: context.l10n.editMetadataAutoFillSource, - subtitle: [ - currentTitle, - currentArtist, - sourceName, - ].where((value) => value.trim().isNotEmpty).join(' · '), - maxHeightFactor: 0.82, - builder: (sheetContext) => ListView( - padding: const EdgeInsets.only(bottom: 16), - children: [ - _editorOptions( - children: [ - for (var index = 0; index < candidates.length; index++) ...[ - MetadataCandidateTile( - candidate: candidates[index], - onTap: () => Navigator.pop(sheetContext, candidates[index]), - ), - if (index != candidates.length - 1) - Divider( - height: 1, - indent: 88, - endIndent: 16, - color: Theme.of( - sheetContext, - ).colorScheme.outlineVariant.withValues(alpha: 0.3), - ), - ], - ], - ), - ], - ), - ); - } - - Future _fetchAutoFillPreview() async { - if (_fetching || _saving) return; - if (_form.autoFillFields.isEmpty) { - _showSheetSnackBar(context.l10n.editMetadataAutoFillNoneSelected); - return; - } - - setState(() { - _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 = 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 = currentValues['isrc']!.trim().toUpperCase(); - final configuredProviderId = _selectedMetadataProviderId?.trim(); - final extensionState = ProviderScope.containerOf( - context, - listen: false, - ).read(extensionProvider); - final selectedProvider = configuredProviderId == null - ? null - : extensionState.extensions - .where( - (extension) => - extension.id == configuredProviderId && - extension.enabled && - extension.hasMetadataProvider, - ) - .firstOrNull; - final selectedProviderId = selectedProvider?.id; - final usesAutomaticProvider = - selectedProviderId == null || selectedProviderId.isEmpty; - final needsTrackLookup = fields.any((key) => key != 'lyrics'); - Map? best; - - final queryParts = []; - if (title.isNotEmpty) queryParts.add(title); - if (searchArtist.isNotEmpty) queryParts.add(searchArtist); - if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album); - - if (needsTrackLookup && queryParts.isEmpty) { - if (mounted) { - _showSheetSnackBar(context.l10n.editMetadataAutoFillNoResults); - } - return; - } - - final normalizedTitle = _autofill.normalizeText(title); - final normalizedArtist = _autofill.normalizeText(searchArtist); - final normalizedAlbum = _autofill.normalizeText(album); - - if (needsTrackLookup) { - final query = queryParts.join(' '); - final results = await _searchAutoFillCandidates( - query: query, - usesAutomaticProvider: usesAutomaticProvider, - selectedProvider: selectedProvider, - extensionState: extensionState, - ); - - if (!mounted || !isCurrent()) return; - - if (results.isEmpty) { - _showSheetSnackBar(context.l10n.editMetadataAutoFillNoResults); - return; - } - - final rankedResults = [...results] - ..sort((a, b) { - final aScore = _autofill.matchScore( - a, - currentTitle: normalizedTitle, - currentArtist: normalizedArtist, - currentAlbum: normalizedAlbum, - currentIsrc: currentIsrc, - ); - final bScore = _autofill.matchScore( - b, - currentTitle: normalizedTitle, - currentArtist: normalizedArtist, - currentAlbum: normalizedAlbum, - currentIsrc: currentIsrc, - ); - return bScore.compareTo(aScore); - }); - - if (usesAutomaticProvider) { - best = rankedResults.first; - if (!_autofill.matchIsConfident( - best, - currentTitle: normalizedTitle, - currentArtist: normalizedArtist, - currentAlbum: normalizedAlbum, - currentIsrc: currentIsrc, - )) { - best = null; - } - } else { - best = await _showAutoFillCandidatePicker( - candidates: rankedResults, - sourceName: _metadataProviderName(selectedProviderId), - currentTitle: title, - currentArtist: searchArtist, - ); - if (!mounted || !isCurrent() || best == null) return; - } - - if (best == null) { - _showSheetSnackBar(context.l10n.editMetadataAutoFillNoResults); - return; - } - - best = await _autofill.resolveDetails( - best, - providerId: usesAutomaticProvider - ? best['provider_id']?.toString().trim() ?? '' - : selectedProviderId, - includeCover: fields.contains('cover'), - ); - } - - final selectedBest = best; - if (needsTrackLookup && selectedBest == null) { - throw StateError('No metadata match resolved for auto-fill'); - } - - 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), - ); - if (!hasSelectedValue) { - _showSheetSnackBar(context.l10n.editMetadataAutoFillNoResults); - return; - } - - final coverPreview = - _form.autoFillFields.contains('cover') && - coverUrl != null && - coverUrl.isNotEmpty - ? await _covers.download(coverUrl) - : null; - if (!mounted || !isCurrent()) { - if (coverPreview != null) { - await _covers.release(coverPreview.tempDir); - } - return; - } - - final resolvedSourceName = result.lyricsSource?.isNotEmpty == true - ? result.lyricsSource! - : _metadataProviderName(result.sourceId); - setState(() { - _autoFillPreview = _AutoFillPreview( - values: availableValues, - sourceName: resolvedSourceName, - coverUrl: coverUrl?.isNotEmpty == true ? coverUrl : null, - coverPath: coverPreview?.path, - coverTempDir: coverPreview?.tempDir, - coverDetails: coverPreview?.details, - ); - if (coverPreview?.details case final details?) { - _coverDetails[coverPreview!.path] = details; - } - }); - } catch (e, stackTrace) { - _log.e('Metadata auto-fill failed: $e', e, stackTrace); - if (mounted && isCurrent()) { - _showSheetSnackBar( - context.l10n.snackbarError(context.friendlyError(e)), - ); - } - } finally { - if (mounted) setState(() => _fetching = false); - } - } - - Future _applyAutoFillPreview() async { - final preview = _autoFillPreview; - if (preview == null || _saving || _fetching) return; - - setState(() => _fetching = true); - try { - var filledCount = _form.apply(preview.values); - var coverDownloadFailed = false; - - 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 _covers.download(preview.coverUrl!); - coverPath = downloaded?.path; - coverTempDir = downloaded?.tempDir; - coverDetails = downloaded?.details; - } - if (coverPath != null && coverTempDir != null) { - await _cleanupSelectedCoverTemp(); - if (mounted) { - _selectedCoverPath = coverPath; - _selectedCoverTempDir = coverTempDir; - _selectedCoverName = EditMetadataSheet._onlineCoverSentinel; - if (coverDetails != null) { - _coverDetails[coverPath] = coverDetails; - } else { - unawaited(_loadCoverDetails(coverPath)); - } - filledCount++; - } - } else { - coverDownloadFailed = true; - } - } - - if (!mounted) return; - setState(() { - if (filledCount > 0) { - final previewCoverWasTransferred = - preview.coverPath != null && - preview.coverPath == _selectedCoverPath; - if (previewCoverWasTransferred) { - _autoFillPreview = null; - } else { - _invalidateAutoFillPreview(); - } - } - }); - _showSheetSnackBar( - coverDownloadFailed - ? context.l10n.updateDownloadFailed - : filledCount > 0 - ? context.l10n.editMetadataAutoFillDoneFromSource( - filledCount, - preview.sourceName, - ) - : context.l10n.editMetadataAutoFillNoResults, - ); - } catch (e) { - if (mounted) { - _showSheetSnackBar( - context.l10n.snackbarError(context.friendlyError(e)), - ); - } - } finally { - if (mounted) setState(() => _fetching = false); - } - } - @override void initState() { super.initState(); @@ -1151,326 +538,6 @@ class _EditMetadataSheetState extends State { ); } - Widget _buildAutoFillSection(ColorScheme cs) { - return _sectionCard( - icon: Icons.travel_explore, - title: context.l10n.editMetadataAutoFill, - onHeaderTap: () => setState(() => _showAutoFill = !_showAutoFill), - expanded: _showAutoFill, - children: [ - if (_showAutoFill) ...[ - Text( - context.l10n.editMetadataAutoFillDesc, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - const SizedBox(height: 12), - Consumer( - builder: (context, ref, _) { - final extensionState = ref.watch(extensionProvider); - final providers = _autofill.orderedProviders(extensionState); - final selectedProviderIsAvailable = providers.any( - (extension) => extension.id == _selectedMetadataProviderId, - ); - final selectedId = selectedProviderIsAvailable - ? _selectedMetadataProviderId - : null; - return _selectionField( - label: context.l10n.editMetadataAutoFillSource, - value: _metadataProviderName(selectedId), - enabled: !_fetching && !_saving, - onTap: () => _showMetadataProviderPicker(providers), - ); - }, - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _quickSelectButton( - label: context.l10n.editMetadataSelectAll, - onTap: _selectAllFields, - cs: cs, - ), - _quickSelectButton( - label: context.l10n.editMetadataSelectEmpty, - onTap: _selectEmptyFields, - cs: cs, - ), - _quickSelectButton( - label: context.l10n.editMetadataSelectNone, - onTap: _selectNoFields, - cs: cs, - ), - ], - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: MetadataEditorController.fieldKeys.map((key) { - final selected = _form.autoFillFields.contains(key); - if (context.isMornye) { - return MornyeFilterChip( - label: _fieldLabel(key), - selected: selected, - glass: false, - icon: selected - ? CupertinoIcons.checkmark_circle_fill - : CupertinoIcons.circle, - onTap: _fetching - ? null - : () => setState(() { - _invalidateAutoFillPreview(); - if (selected) { - _form.autoFillFields.remove(key); - } else { - _form.autoFillFields.add(key); - } - }), - ); - } - return AppChoiceChip( - label: Text(_fieldLabel(key)), - selected: selected, - onSelected: _fetching - ? null - : (val) { - setState(() { - _invalidateAutoFillPreview(); - if (val) { - _form.autoFillFields.add(key); - } else { - _form.autoFillFields.remove(key); - } - }); - }, - ); - }).toList(), - ), - const SizedBox(height: 14), - SizedBox( - width: double.infinity, - child: AppActionButton( - glass: false, - onPressed: (_fetching || _saving || _form.autoFillFields.isEmpty) - ? null - : _fetchAutoFillPreview, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - icon: _fetching - ? const AppLoadingIndicator( - size: 16, - strokeWidth: 2, - color: Colors.white, - ) - : const Icon(Icons.auto_fix_high), - label: Text( - _fetching - ? context.l10n.editMetadataAutoFillSearching - : context.l10n.editMetadataAutoFillFind, - ), - ), - ), - if (_autoFillPreview case final preview?) ...[ - const SizedBox(height: 12), - _buildAutoFillPreview(cs, preview), - ], - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: AppActionButton( - outlined: true, - glass: false, - tonal: true, - onPressed: (_fetching || _fetchingMusicBrainz || _saving) - ? null - : _fetchFromMusicBrainz, - style: OutlinedButton.styleFrom( - backgroundColor: cs.surfaceContainerHigh, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - icon: _fetchingMusicBrainz - ? const AppLoadingIndicator(size: 16, strokeWidth: 2) - : const Icon(Icons.library_music_outlined), - label: Text(context.l10n.editMetadataMusicBrainzButton), - ), - ), - const SizedBox(height: 8), - ], - ], - ); - } - - Widget _buildAutoFillPreview(ColorScheme cs, _AutoFillPreview preview) { - final previewFields = _form.autoFillFields - .where((key) { - if (key == 'cover') return preview.coverUrl != null; - return preview.values.containsKey(key); - }) - .toList(growable: false); - - return Container( - width: double.infinity, - padding: context.isMornye - ? const EdgeInsets.symmetric(vertical: 12) - : const EdgeInsets.all(12), - decoration: context.isMornye - ? null - : BoxDecoration( - color: cs.surfaceContainerHighest.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: cs.outlineVariant), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.fact_check_outlined, size: 18, color: cs.primary), - const SizedBox(width: 8), - Expanded( - child: Text( - context.l10n.editMetadataAutoFillPreview(preview.sourceName), - style: Theme.of(context).textTheme.titleSmall, - ), - ), - ], - ), - const SizedBox(height: 8), - if (preview.coverPath case final coverPath?) ...[ - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.file( - File(coverPath), - width: 112, - height: 112, - fit: BoxFit.cover, - cacheWidth: (112 * MediaQuery.devicePixelRatioOf(context)) - .round(), - cacheHeight: (112 * MediaQuery.devicePixelRatioOf(context)) - .round(), - filterQuality: FilterQuality.low, - semanticLabel: - context.l10n.editMetadataAutoFillCoverAvailable, - errorBuilder: (_, _, _) => const SizedBox.shrink(), - ), - ), - ), - if (preview.coverDetails case final details?) ...[ - const SizedBox(height: 6), - Center( - child: Text( - '${details.width} × ${details.height} px · ' - '${_formatCoverBytes(details.bytes)}', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: cs.primary, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - const SizedBox(height: 10), - ], - ...previewFields.map((key) { - final details = preview.coverDetails; - final value = key == 'cover' - ? details != null - ? '${details.width} × ${details.height} px · ' - '${_formatCoverBytes(details.bytes)}' - : context.l10n.editMetadataAutoFillCoverAvailable - : preview.values[key]!; - return Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 104, - child: Text( - _fieldLabel(key), - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: cs.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - value, - maxLines: key == 'lyrics' ? 3 : 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - ), - ); - }), - const SizedBox(height: 6), - SizedBox( - width: double.infinity, - child: AppActionButton( - outlined: context.isMornye, - glass: false, - onPressed: (_fetching || _saving) ? null : _applyAutoFillPreview, - icon: const Icon(Icons.check), - label: Text(context.l10n.editMetadataAutoFillApply), - ), - ), - ], - ), - ); - } - - /// Fills genre and album artist from MusicBrainz (keyed by ISRC) as - /// editable suggestions; nothing is saved until the user taps Save. - Future _fetchFromMusicBrainz() async { - 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: _form['album']!.text.trim(), - ); - if (!mounted || !_form.isCurrent(generation)) return; - var filled = 0; - if (tags.genre.isNotEmpty) { - _form['genre']!.text = tags.genre; - filled++; - } - if (tags.albumArtist.isNotEmpty) { - _form['album_artist']!.text = tags.albumArtist; - filled++; - } - _showSheetSnackBar( - filled > 0 - ? context.l10n.editMetadataMusicBrainzFilled - : context.l10n.editMetadataMusicBrainzNothing, - ); - } catch (_) { - if (mounted) { - _showSheetSnackBar(context.l10n.editMetadataMusicBrainzNothing); - } - } finally { - if (mounted) setState(() => _fetchingMusicBrainz = false); - } - } - Widget _editorOptions({required List children}) { if (!context.isMornye) return SettingsGroup(children: children); return Padding( @@ -1520,201 +587,6 @@ class _EditMetadataSheetState extends State { ); } - Widget _buildCoverEditor(ColorScheme cs) { - final hasSelectedCover = _hasValue(_selectedCoverPath); - final hasCurrentCover = _hasValue(_currentCoverPath); - return _sectionCard( - icon: Icons.image_outlined, - title: context.l10n.editMetadataFieldCover, - children: [ - if (_loadingCurrentCover) - const Padding( - padding: EdgeInsets.only(bottom: 8), - child: AppProgressIndicator.linear(minHeight: 2), - ) - else if (!hasCurrentCover && !hasSelectedCover) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - context.l10n.trackCoverNoEmbeddedArt, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - ), - Row( - children: [ - Expanded( - child: AppActionButton( - outlined: true, - glass: false, - onPressed: _saving ? null : _pickCoverImage, - icon: const Icon(Icons.image_outlined), - label: Text( - hasSelectedCover - ? context.l10n.trackCoverReplace - : context.l10n.trackCoverPick, - ), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - if (hasSelectedCover) ...[ - const SizedBox(width: 8), - AppIconButton( - tooltip: context.l10n.trackCoverClearSelected, - onPressed: _saving - ? null - : () async { - await _cleanupSelectedCoverTemp(); - if (!mounted) return; - setState(() {}); - }, - icon: const Icon(Icons.close), - ), - ], - ], - ), - if (hasCurrentCover || hasSelectedCover) ...[ - const SizedBox(height: 12), - Row( - children: [ - if (hasCurrentCover) - Expanded( - child: _buildCoverPreviewTile( - cs: cs, - path: _currentCoverPath!, - label: context.l10n.trackCoverCurrent, - ), - ), - if (hasCurrentCover && hasSelectedCover) - const SizedBox(width: 12), - if (hasSelectedCover) - Expanded( - child: _buildCoverPreviewTile( - cs: cs, - path: _selectedCoverPath!, - label: - _selectedCoverName == - EditMetadataSheet._onlineCoverSentinel - ? context.l10n.trackCoverOnline - : (_selectedCoverName ?? - context.l10n.trackCoverSelected), - ), - ), - ], - ), - if (hasSelectedCover) ...[ - const SizedBox(height: 8), - Text( - context.l10n.trackCoverReplaceNotice, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - ], - const SizedBox(height: 12), - _selectionField( - label: context.l10n.trackCoverResolution, - value: _coverMaxDimension == null - ? _originalCoverResolutionLabel( - context.l10n.trackConvertOriginal, - ) - : '${_coverMaxDimension!} px', - enabled: !_saving, - onTap: _showCoverResolutionPicker, - ), - Text( - context.l10n.trackCoverResolutionHint, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - ], - const SizedBox(height: 8), - ], - ); - } - - Widget _buildCoverPreviewTile({ - required ColorScheme cs, - required String path, - required String label, - }) { - final details = _coverDetails[path]; - final loadingDetails = _coverDetailLoads.contains(path); - return Column( - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: cs.shadow.withValues(alpha: 0.15), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - File(path), - height: 160, - width: 160, - fit: BoxFit.cover, - cacheWidth: (160 * MediaQuery.devicePixelRatioOf(context)) - .round(), - cacheHeight: (160 * MediaQuery.devicePixelRatioOf(context)) - .round(), - filterQuality: FilterQuality.low, - errorBuilder: (_, _, _) => Container( - width: 160, - height: 160, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.broken_image, - color: cs.onSurfaceVariant, - size: 32, - ), - ), - ), - ), - ), - const SizedBox(height: 8), - Text( - label, - style: Theme.of( - context, - ).textTheme.labelMedium?.copyWith(color: cs.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 3), - if (details != null) - Text( - '${details.width} × ${details.height} px · ' - '${_formatCoverBytes(details.bytes)}', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: cs.primary, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - else if (loadingDetails) - AppLoadingIndicator(size: 12, strokeWidth: 1.5, color: cs.primary), - ], - ); - } - Widget _switchField({ required String label, required String subtitle,