refactor(metadata): split the edit sheet into cover and auto-fill parts

Cover editor members and auto-fill members move to extensions in part
files; bodies are unchanged except setState goes through a _setState
forwarder, as in the other State part files.
This commit is contained in:
zarzet committed 2026-10-18 22:39:31 +07:00
1 parent 77377f6b67
commit 714bec6aaf
3 files changed
+1150 -1134

No files matched your search

@@ -0,0 +1,797 @@
part of 'track_metadata_edit_sheet.dart';
class _AutoFillPreview {
final Map<String, String> 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<void> _showMetadataProviderPicker(List<Extension> 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<List<Map<String, dynamic>>> _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<Map<String, dynamic>?> _showAutoFillCandidatePicker({
required List<Map<String, dynamic>> candidates,
required String sourceName,
required String currentTitle,
required String currentArtist,
}) {
return showAppBottomSheet<Map<String, dynamic>>(
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<void> _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<String>.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<String, dynamic>? best;
final queryParts = <String>[];
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<void> _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<void> _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);
}
}
}
+347
View File
@@ -0,0 +1,347 @@
part of 'track_metadata_edit_sheet.dart';
extension _EditMetadataSheetCover on _EditMetadataSheetState {
Future<void> _showCoverResolutionPicker() async {
final currentValue = _coverMaxDimension ?? 0;
final selectedValue = await _showEditorOptionPicker<int>(
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<void> _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<void> _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<void> _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<void> _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),
],
);
}
}
File diff suppressed because it is too large. Load diff