mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user