SpotiFLAC-Mobile/lib/screens/track_metadata_edit_sheet.dart
zarzet 2a2d817314 feat: add AAC lossy target and toggle for Apple Music eLRC word sync
The HIGH-quality lossy format picker can now produce an AAC/M4A 320 kbps output alongside MP3 and Opus. FFmpegService.convertM4aToLossy/convertAudioFormat, the Dart queue pipeline, the Kotlin finalizer, and the library database format helper all route .m4a through a unified aac codec path and tag the resulting file with the M4A metadata writer. The Lossy Format setting gains a new option, and the track metadata convert dialog lists AAC next to the other targets.

Apple Music lyrics gain a 'eLRC word sync' switch (default off). When disabled the pax-to-LRC formatter strips inline word timestamps, producing line-synced LRC that is safer for players that choke on eLRC; enabling it restores the previous word-by-word behaviour. The change propagates through SetLyricsFetchOptions and invalidates the global lyrics cache on toggle.

Broad l10n migration: roughly 400 previously hardcoded English strings across queue, settings, track metadata, repo, audio analysis, setup and extension screens now live in the ARB catalog, with matching plural/placeholder forms. No behaviour change beyond localisation. Existing and new unit tests (lyrics eLRC toggle and Dart settings round-trip) pass.
2026-05-12 02:23:04 +07:00

1712 lines
56 KiB
Dart

part of 'track_metadata_screen.dart';
class _ResolvedAutoFillTrack {
final Map<String, dynamic> track;
final String? deezerId;
const _ResolvedAutoFillTrack({required this.track, this.deezerId});
}
class _EditMetadataSheet extends StatefulWidget {
final ColorScheme colorScheme;
final Map<String, String> initialValues;
final String filePath;
final String? sourceTrackId;
final int durationMs;
final String artistTagMode;
const _EditMetadataSheet({
required this.colorScheme,
required this.initialValues,
required this.filePath,
this.sourceTrackId,
required this.durationMs,
required this.artistTagMode,
});
@override
State<_EditMetadataSheet> createState() => _EditMetadataSheetState();
}
class _EditMetadataSheetState extends State<_EditMetadataSheet> {
static final RegExp _metadataCollapsePattern = RegExp(r'[^a-z0-9]+');
static final RegExp _metadataWhitespacePattern = RegExp(r'\s+');
static final RegExp _spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
static final RegExp _deezerTrackIdPattern = RegExp(r'^\d+$');
static final RegExp _isrcPattern = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$');
bool _saving = false;
bool _showAdvanced = false;
bool _showAutoFill = false;
bool _fetching = false;
String? _selectedCoverPath;
String? _selectedCoverTempDir;
String? _selectedCoverName;
String? _currentCoverPath;
String? _currentCoverTempDir;
bool _loadingCurrentCover = false;
final Set<String> _autoFillFields = {};
static const _fieldDefs = <String, String>{
'title': 'title',
'artist': 'artist',
'album': 'album',
'album_artist': 'album_artist',
'date': 'date',
'track_number': 'track_number',
'total_tracks': 'total_tracks',
'disc_number': 'disc_number',
'total_discs': 'total_discs',
'genre': 'genre',
'isrc': 'isrc',
'lyrics': 'lyrics',
'label': 'label',
'copyright': 'copyright',
'composer': 'composer',
'cover': 'cover',
};
late final TextEditingController _titleCtrl;
late final TextEditingController _artistCtrl;
late final TextEditingController _albumCtrl;
late final TextEditingController _albumArtistCtrl;
late final TextEditingController _dateCtrl;
late final TextEditingController _trackNumCtrl;
late final TextEditingController _trackTotalCtrl;
late final TextEditingController _discNumCtrl;
late final TextEditingController _discTotalCtrl;
late final TextEditingController _genreCtrl;
late final TextEditingController _isrcCtrl;
late final TextEditingController _lyricsCtrl;
late final TextEditingController _labelCtrl;
late final TextEditingController _copyrightCtrl;
late final TextEditingController _composerCtrl;
late final TextEditingController _commentCtrl;
bool _hasValue(String? value) => value != null && value.trim().isNotEmpty;
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 dirPath = _selectedCoverTempDir;
_selectedCoverPath = null;
_selectedCoverTempDir = null;
_selectedCoverName = null;
if (dirPath == null || dirPath.isEmpty) return;
try {
final dir = Directory(dirPath);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
} catch (_) {}
}
Future<void> _cleanupCurrentCoverTemp() async {
final dirPath = _currentCoverTempDir;
_currentCoverPath = null;
_currentCoverTempDir = null;
if (dirPath == null || dirPath.isEmpty) return;
try {
final dir = Directory(dirPath);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
} catch (_) {}
}
Future<void> _loadCurrentCoverPreview() async {
if (_loadingCurrentCover) return;
setState(() => _loadingCurrentCover = true);
String? newCoverPath;
String? newCoverDir;
try {
final tempDir = await Directory.systemTemp.createTemp(
'edit_existing_cover_',
);
final coverOutput =
'${tempDir.path}${Platform.pathSeparator}existing_cover.jpg';
final coverResult = await PlatformBridge.extractCoverToFile(
widget.filePath,
coverOutput,
);
if (coverResult['error'] == null && await File(coverOutput).exists()) {
newCoverPath = coverOutput;
newCoverDir = tempDir.path;
} else {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
}
} catch (_) {}
if (!mounted) {
if (newCoverDir != null) {
try {
final dir = Directory(newCoverDir);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
} catch (_) {}
}
return;
}
final oldDir = _currentCoverTempDir;
setState(() {
_currentCoverPath = newCoverPath;
_currentCoverTempDir = newCoverDir;
_loadingCurrentCover = false;
});
if (oldDir != null && oldDir.isNotEmpty && oldDir != newCoverDir) {
try {
final dir = Directory(oldDir);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
} catch (_) {}
}
}
Future<void> _pickCoverImage() async {
try {
final result = await FilePicker.pickFiles(
type: FileType.image,
allowMultiple: false,
withData: true,
);
if (result == null || result.files.isEmpty) return;
final picked = result.files.first;
final bytes = picked.bytes;
final sourcePath = picked.path;
final extension = _resolveImageExtension(picked.extension, bytes);
final tempDir = await Directory.systemTemp.createTemp('edit_cover_');
final tempPath =
'${tempDir.path}${Platform.pathSeparator}cover.$extension';
if (bytes != null && bytes.isNotEmpty) {
await File(tempPath).writeAsBytes(bytes, flush: true);
} else if (sourcePath != null && sourcePath.isNotEmpty) {
final sourceFile = File(sourcePath);
if (!await sourceFile.exists()) {
throw Exception('Selected image is not accessible');
}
await sourceFile.copy(tempPath);
} else {
throw Exception('Unable to read selected image');
}
await _cleanupSelectedCoverTemp();
if (!mounted) {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
return;
}
setState(() {
_selectedCoverPath = tempPath;
_selectedCoverTempDir = tempDir.path;
_selectedCoverName = picked.name;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
);
}
}
String _fieldLabel(String key) {
final l10n = context.l10n;
switch (key) {
case 'title':
return l10n.editMetadataFieldTitle;
case 'artist':
return l10n.editMetadataFieldArtist;
case 'album':
return l10n.editMetadataFieldAlbum;
case 'album_artist':
return l10n.editMetadataFieldAlbumArtist;
case 'date':
return l10n.editMetadataFieldDate;
case 'track_number':
return l10n.editMetadataFieldTrackNum;
case 'total_tracks':
return l10n.editMetadataFieldTrackTotal;
case 'disc_number':
return l10n.editMetadataFieldDiscNum;
case 'total_discs':
return l10n.editMetadataFieldDiscTotal;
case 'genre':
return l10n.editMetadataFieldGenre;
case 'isrc':
return l10n.editMetadataFieldIsrc;
case 'lyrics':
return l10n.trackLyrics;
case 'label':
return l10n.editMetadataFieldLabel;
case 'copyright':
return l10n.editMetadataFieldCopyright;
case 'composer':
return l10n.editMetadataFieldComposer;
case 'cover':
return l10n.editMetadataFieldCover;
default:
return key;
}
}
TextEditingController? _controllerForKey(String key) {
switch (key) {
case 'title':
return _titleCtrl;
case 'artist':
return _artistCtrl;
case 'album':
return _albumCtrl;
case 'album_artist':
return _albumArtistCtrl;
case 'date':
return _dateCtrl;
case 'track_number':
return _trackNumCtrl;
case 'total_tracks':
return _trackTotalCtrl;
case 'disc_number':
return _discNumCtrl;
case 'total_discs':
return _discTotalCtrl;
case 'genre':
return _genreCtrl;
case 'isrc':
return _isrcCtrl;
case 'lyrics':
return _lyricsCtrl;
case 'label':
return _labelCtrl;
case 'copyright':
return _copyrightCtrl;
case 'composer':
return _composerCtrl;
default:
return null;
}
}
void _selectAllFields() {
setState(() {
_autoFillFields.addAll(_fieldDefs.keys);
});
}
void _selectEmptyFields() {
setState(() {
_autoFillFields.clear();
for (final key in _fieldDefs.keys) {
if (key == 'cover') {
if (!_hasValue(_currentCoverPath) && !_hasValue(_selectedCoverPath)) {
_autoFillFields.add(key);
}
continue;
}
final ctrl = _controllerForKey(key);
if (ctrl != null && ctrl.text.trim().isEmpty) {
_autoFillFields.add(key);
}
}
});
}
String _normalizeMetadataText(String value) {
final collapsed = value
.toLowerCase()
.replaceAll(_metadataCollapsePattern, ' ')
.trim();
return collapsed.replaceAll(_metadataWhitespacePattern, ' ');
}
bool _looksLikeIsrc(String value) {
return _isrcPattern.hasMatch(value.trim().toUpperCase());
}
String? _extractRawSpotifyTrackIdFromValue(Object? value) {
final raw = value?.toString().trim() ?? '';
if (raw.isEmpty) return null;
if (_spotifyTrackIdPattern.hasMatch(raw)) {
return raw;
}
if (raw.startsWith('spotify:')) {
final parts = raw.split(':');
final last = parts.isNotEmpty ? parts.last.trim() : '';
if (_spotifyTrackIdPattern.hasMatch(last)) {
return last;
}
return null;
}
final uri = Uri.tryParse(raw);
if (uri != null &&
uri.host.contains('spotify.com') &&
uri.pathSegments.length >= 2 &&
uri.pathSegments.first == 'track') {
final candidate = uri.pathSegments[1].trim();
if (_spotifyTrackIdPattern.hasMatch(candidate)) {
return candidate;
}
}
return null;
}
String? _extractRawDeezerTrackIdFromValue(Object? value) {
final raw = value?.toString().trim() ?? '';
if (raw.isEmpty) return null;
if (_deezerTrackIdPattern.hasMatch(raw)) {
return raw;
}
if (raw.startsWith('deezer:')) {
final parts = raw.split(':');
final last = parts.isNotEmpty ? parts.last.trim() : '';
if (_deezerTrackIdPattern.hasMatch(last)) {
return last;
}
}
final uri = Uri.tryParse(raw);
if (uri != null && uri.host.contains('deezer.com')) {
final trackIndex = uri.pathSegments.indexOf('track');
if (trackIndex >= 0 && trackIndex + 1 < uri.pathSegments.length) {
final candidate = uri.pathSegments[trackIndex + 1].trim();
if (_deezerTrackIdPattern.hasMatch(candidate)) {
return candidate;
}
}
}
return null;
}
String? _extractRawSpotifyTrackId(Map<String, dynamic> track) {
for (final candidate in [track['spotify_id'], track['id']]) {
final spotifyId = _extractRawSpotifyTrackIdFromValue(candidate);
if (spotifyId != null) return spotifyId;
}
final externalLinks = track['external_links'];
if (externalLinks is Map) {
final spotifyId = _extractRawSpotifyTrackIdFromValue(
externalLinks['spotify'],
);
if (spotifyId != null) return spotifyId;
}
return null;
}
String? _extractRawDeezerTrackId(Map<String, dynamic> track) {
for (final candidate in [
track['deezer_id'],
track['spotify_id'],
track['id'],
]) {
final deezerId = _extractRawDeezerTrackIdFromValue(candidate);
if (deezerId != null) return deezerId;
}
final externalLinks = track['external_links'];
if (externalLinks is Map) {
final deezerId = _extractRawDeezerTrackIdFromValue(
externalLinks['deezer'],
);
if (deezerId != null) return deezerId;
}
return null;
}
Map<String, dynamic> _unwrapTrackPayload(Map<String, dynamic> payload) {
final track = payload['track'];
if (track is Map<String, dynamic>) {
return track;
}
return payload;
}
void _mergeOnlineTrackData(
Map<String, String> enriched,
Map<String, dynamic> track,
) {
void put(String key, Object? value) {
final text = value?.toString().trim() ?? '';
if (text.isNotEmpty && text != 'null') {
enriched[key] = text;
}
}
put('title', track['name'] ?? track['title']);
put('artist', track['artists'] ?? track['artist']);
put('album', track['album_name'] ?? track['album']);
put('album_artist', track['album_artist']);
put('date', track['release_date']);
put('track_number', track['track_number']);
put('total_tracks', track['total_tracks']);
put('disc_number', track['disc_number']);
put('total_discs', track['total_discs']);
put('isrc', track['isrc']);
put('genre', track['genre']);
put('label', track['label']);
put('copyright', track['copyright']);
put('composer', track['composer']);
}
Future<_ResolvedAutoFillTrack?> _resolveAutoFillTrackFromIdentifiers(
String currentIsrc,
) async {
if (_looksLikeIsrc(currentIsrc)) {
final deezerTrack = await PlatformBridge.searchDeezerByISRC(currentIsrc);
return _ResolvedAutoFillTrack(
track: _unwrapTrackPayload(deezerTrack),
deezerId: _extractRawDeezerTrackId(deezerTrack),
);
}
final sourceTrackId = widget.sourceTrackId?.trim() ?? '';
if (sourceTrackId.isEmpty) {
return null;
}
final deezerId = _extractRawDeezerTrackIdFromValue(sourceTrackId);
if (deezerId != null) {
final deezerTrack = await PlatformBridge.getProviderMetadata(
'deezer',
'track',
deezerId,
);
return _ResolvedAutoFillTrack(
track: _unwrapTrackPayload(deezerTrack),
deezerId: deezerId,
);
}
final spotifyId = _extractRawSpotifyTrackIdFromValue(sourceTrackId);
if (spotifyId != null) {
final deezerTrack = await PlatformBridge.convertSpotifyToDeezer(
'track',
spotifyId,
);
final track = _unwrapTrackPayload(deezerTrack);
return _ResolvedAutoFillTrack(
track: track,
deezerId:
_extractRawDeezerTrackId(track) ??
_extractRawDeezerTrackId(deezerTrack),
);
}
return null;
}
int _metadataMatchScore(
Map<String, dynamic> track, {
required String currentTitle,
required String currentArtist,
required String currentAlbum,
required String currentIsrc,
}) {
var score = 0;
final candidateIsrc = (track['isrc']?.toString() ?? '')
.trim()
.toUpperCase();
if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) {
score += 10000;
}
final candidateTitle = _normalizeMetadataText(
(track['name'] ?? track['title'] ?? '').toString(),
);
final candidateArtist = _normalizeMetadataText(
(track['artists'] ?? track['artist'] ?? '').toString(),
);
final candidateAlbum = _normalizeMetadataText(
(track['album_name'] ?? track['album'] ?? '').toString(),
);
if (currentTitle.isNotEmpty && candidateTitle.isNotEmpty) {
if (candidateTitle == currentTitle) {
score += 400;
} else if (candidateTitle.contains(currentTitle) ||
currentTitle.contains(candidateTitle)) {
score += 180;
}
}
if (currentArtist.isNotEmpty && candidateArtist.isNotEmpty) {
if (candidateArtist == currentArtist) {
score += 320;
} else if (candidateArtist.contains(currentArtist) ||
currentArtist.contains(candidateArtist)) {
score += 140;
}
}
if (currentAlbum.isNotEmpty && candidateAlbum.isNotEmpty) {
if (candidateAlbum == currentAlbum) {
score += 120;
} else if (candidateAlbum.contains(currentAlbum) ||
currentAlbum.contains(candidateAlbum)) {
score += 50;
}
}
return score;
}
Future<void> _fetchAndFill() async {
if (_autoFillFields.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.editMetadataAutoFillNoneSelected)),
);
return;
}
setState(() => _fetching = true);
try {
final title = _titleCtrl.text.trim();
final artist = _artistCtrl.text.trim();
final album = _albumCtrl.text.trim();
final currentIsrc = _isrcCtrl.text.trim().toUpperCase();
final shouldFetchLyrics = _autoFillFields.contains('lyrics');
final needsTrackLookup = _autoFillFields.any((key) => key != 'lyrics');
Map<String, dynamic>? best;
String? deezerId;
if (needsTrackLookup) {
try {
final resolved = await _resolveAutoFillTrackFromIdentifiers(
currentIsrc,
);
if (resolved != null) {
best = resolved.track;
deezerId = resolved.deezerId;
}
} catch (e) {
_log.w('Identifier-first autofill lookup failed: $e');
}
}
final queryParts = <String>[];
if (title.isNotEmpty) queryParts.add(title);
if (artist.isNotEmpty) queryParts.add(artist);
if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album);
if (needsTrackLookup && best == null && queryParts.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)),
);
}
return;
}
final normalizedTitle = _normalizeMetadataText(title);
final normalizedArtist = _normalizeMetadataText(artist);
final normalizedAlbum = _normalizeMetadataText(album);
if (needsTrackLookup && best == null) {
final query = queryParts.join(' ');
final results = await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 5,
);
if (!mounted) return;
if (results.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)),
);
return;
}
// Pick best match using current metadata, not only provider order.
best = results.first;
var bestScore = -1;
for (final result in results) {
final score = _metadataMatchScore(
result,
currentTitle: normalizedTitle,
currentArtist: normalizedArtist,
currentAlbum: normalizedAlbum,
currentIsrc: currentIsrc,
);
if (score > bestScore) {
bestScore = score;
best = result;
}
}
}
final selectedBest = best;
if (needsTrackLookup && selectedBest == null) {
throw StateError('No metadata match resolved for auto-fill');
}
final enriched = <String, String>{};
if (selectedBest != null) {
enriched.addAll(<String, String>{
'title': (selectedBest['name'] ?? '').toString(),
'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '')
.toString(),
'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '')
.toString(),
'album_artist': (selectedBest['album_artist'] ?? '').toString(),
'date': (selectedBest['release_date'] ?? '').toString(),
'track_number': (selectedBest['track_number'] ?? '').toString(),
'total_tracks': (selectedBest['total_tracks'] ?? '').toString(),
'disc_number': (selectedBest['disc_number'] ?? '').toString(),
'total_discs': (selectedBest['total_discs'] ?? '').toString(),
'isrc': (selectedBest['isrc'] ?? '').toString(),
'composer': (selectedBest['composer'] ?? '').toString(),
});
_mergeOnlineTrackData(enriched, selectedBest);
}
final enrichedIsrc = (enriched['isrc'] ?? '').trim();
final needsIsrc =
_autoFillFields.contains('isrc') && enrichedIsrc.isEmpty;
final needsExtended =
_autoFillFields.contains('genre') ||
_autoFillFields.contains('label') ||
_autoFillFields.contains('copyright') ||
_autoFillFields.contains('composer');
final rawSpotifyId = selectedBest == null
? _extractRawSpotifyTrackIdFromValue(widget.sourceTrackId)
: _extractRawSpotifyTrackId(selectedBest);
deezerId ??= selectedBest == null
? null
: _extractRawDeezerTrackId(selectedBest);
final candidateIsrc = enrichedIsrc.toUpperCase();
final deezerLookupIsrc = _looksLikeIsrc(currentIsrc)
? currentIsrc
: (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : '');
if (needsIsrc || needsExtended) {
try {
if (deezerId == null && deezerLookupIsrc.isNotEmpty) {
final deezerResult = await PlatformBridge.searchDeezerByISRC(
deezerLookupIsrc,
);
deezerId = _extractRawDeezerTrackId(deezerResult);
_mergeOnlineTrackData(enriched, deezerResult);
}
if (deezerId == null && rawSpotifyId != null) {
// Spotify IDs can be mapped through SongLink to a Deezer track.
final deezerData = await PlatformBridge.convertSpotifyToDeezer(
'track',
rawSpotifyId,
);
final trackData = deezerData['track'];
if (trackData is Map<String, dynamic>) {
deezerId = _extractRawDeezerTrackId(trackData);
_mergeOnlineTrackData(enriched, trackData);
}
deezerId ??= _extractRawDeezerTrackId(deezerData);
}
} catch (_) {
// Deezer resolution is best-effort
}
}
if (!mounted) return;
// Fetch ISRC from Deezer track metadata if still missing
if (needsIsrc &&
(enriched['isrc'] ?? '').trim().isEmpty &&
deezerId != null) {
try {
final deezerMeta = await PlatformBridge.getProviderMetadata(
'deezer',
'track',
deezerId,
);
final trackData = _unwrapTrackPayload(deezerMeta);
_mergeOnlineTrackData(enriched, trackData);
final deezerIsrc = (trackData['isrc'] ?? '').toString().trim();
if (deezerIsrc.isNotEmpty) {
enriched['isrc'] = deezerIsrc;
}
} catch (_) {}
}
if (!mounted) return;
if (needsExtended && deezerId != null) {
try {
final extended = await PlatformBridge.getDeezerExtendedMetadata(
deezerId,
);
if (extended != null) {
enriched['genre'] = extended['genre'] ?? '';
enriched['label'] = extended['label'] ?? '';
enriched['copyright'] = extended['copyright'] ?? '';
}
} catch (_) {
// Extended metadata is best-effort
}
}
if (shouldFetchLyrics) {
final lyricsTitle =
((selectedBest?['name'] ?? selectedBest?['title'] ?? title)
.toString())
.trim();
final lyricsArtist =
((selectedBest?['artists'] ?? selectedBest?['artist'] ?? artist)
.toString())
.trim();
if (lyricsTitle.isNotEmpty && lyricsArtist.isNotEmpty) {
try {
final lyricsResult = await PlatformBridge.getLyricsLRCWithSource(
rawSpotifyId ?? '',
lyricsTitle,
lyricsArtist,
durationMs: widget.durationMs,
);
final lyricsText = lyricsResult['lyrics']?.toString().trim() ?? '';
final instrumental =
(lyricsResult['instrumental'] as bool? ?? false) ||
lyricsText == '[instrumental:true]';
if (!instrumental && lyricsText.isNotEmpty) {
enriched['lyrics'] = lyricsText;
}
} catch (e) {
_log.w('Lyrics autofill failed: $e');
}
}
}
if (!mounted) return;
var filledCount = 0;
for (final key in _autoFillFields) {
if (key == 'cover') continue;
final value = enriched[key];
if (value != null &&
value.isNotEmpty &&
value != '0' &&
value != 'null') {
final ctrl = _controllerForKey(key);
if (ctrl != null) {
ctrl.text = value;
filledCount++;
}
}
}
if (_autoFillFields.contains('cover') && selectedBest != null) {
final coverUrl =
(selectedBest['cover_url'] ?? selectedBest['images'] ?? '')
.toString();
if (coverUrl.isNotEmpty) {
try {
final tempDir = await Directory.systemTemp.createTemp(
'autofill_cover_',
);
final coverOutput =
'${tempDir.path}${Platform.pathSeparator}cover.jpg';
final response = await HttpClient()
.getUrl(Uri.parse(coverUrl))
.then((req) => req.close());
final file = File(coverOutput);
final sink = file.openWrite();
await response.pipe(sink);
if (await file.exists() && await file.length() > 0) {
await _cleanupSelectedCoverTemp();
if (mounted) {
setState(() {
_selectedCoverPath = coverOutput;
_selectedCoverTempDir = tempDir.path;
_selectedCoverName = 'Online cover';
});
filledCount++;
}
} else {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
}
} catch (_) {
// Cover download is best-effort
}
}
}
if (mounted) {
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
filledCount > 0
? context.l10n.editMetadataAutoFillDone(filledCount)
: context.l10n.editMetadataAutoFillNoResults,
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
);
}
} finally {
if (mounted) setState(() => _fetching = false);
}
}
@override
void initState() {
super.initState();
final v = widget.initialValues;
_titleCtrl = TextEditingController(text: v['title'] ?? '');
_artistCtrl = TextEditingController(text: v['artist'] ?? '');
_albumCtrl = TextEditingController(text: v['album'] ?? '');
_albumArtistCtrl = TextEditingController(text: v['album_artist'] ?? '');
_dateCtrl = TextEditingController(text: v['date'] ?? '');
_trackNumCtrl = TextEditingController(text: v['track_number'] ?? '');
_trackTotalCtrl = TextEditingController(text: v['total_tracks'] ?? '');
_discNumCtrl = TextEditingController(text: v['disc_number'] ?? '');
_discTotalCtrl = TextEditingController(text: v['total_discs'] ?? '');
_genreCtrl = TextEditingController(text: v['genre'] ?? '');
_isrcCtrl = TextEditingController(text: v['isrc'] ?? '');
_lyricsCtrl = TextEditingController(text: v['lyrics'] ?? '');
_labelCtrl = TextEditingController(text: v['label'] ?? '');
_copyrightCtrl = TextEditingController(text: v['copyright'] ?? '');
_composerCtrl = TextEditingController(text: v['composer'] ?? '');
_commentCtrl = TextEditingController(text: v['comment'] ?? '');
_loadCurrentCoverPreview();
}
@override
void dispose() {
unawaited(_cleanupSelectedCoverTemp());
unawaited(_cleanupCurrentCoverTemp());
_titleCtrl.dispose();
_artistCtrl.dispose();
_albumCtrl.dispose();
_albumArtistCtrl.dispose();
_dateCtrl.dispose();
_trackNumCtrl.dispose();
_trackTotalCtrl.dispose();
_discNumCtrl.dispose();
_discTotalCtrl.dispose();
_genreCtrl.dispose();
_isrcCtrl.dispose();
_lyricsCtrl.dispose();
_labelCtrl.dispose();
_copyrightCtrl.dispose();
_composerCtrl.dispose();
_commentCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() => _saving = true);
final metadata = <String, String>{
'title': _titleCtrl.text,
'artist': _artistCtrl.text,
'album': _albumCtrl.text,
'album_artist': _albumArtistCtrl.text,
'date': _dateCtrl.text,
'track_number': _trackNumCtrl.text,
'track_total': _trackTotalCtrl.text,
'disc_number': _discNumCtrl.text,
'disc_total': _discTotalCtrl.text,
'genre': _genreCtrl.text,
'isrc': _isrcCtrl.text,
'lyrics': _lyricsCtrl.text,
'label': _labelCtrl.text,
'copyright': _copyrightCtrl.text,
'composer': _composerCtrl.text,
'comment': _commentCtrl.text,
'cover_path': _selectedCoverPath ?? '',
'artist_tag_mode': widget.artistTagMode,
};
try {
final result = await PlatformBridge.editFileMetadata(
widget.filePath,
metadata,
);
if (result['error'] != null) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('${result['error']}')));
}
setState(() => _saving = false);
return;
}
final method = result['method'] as String?;
if (method == 'ffmpeg') {
// For SAF files, Kotlin returns temp_path + saf_uri
final tempPath = result['temp_path'] as String?;
final safUri = result['saf_uri'] as String?;
final ffmpegTarget = tempPath ?? widget.filePath;
final lower = widget.filePath.toLowerCase();
final isMp3 = lower.endsWith('.mp3');
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac');
// Always include all known fields so -map_metadata 0 + explicit
// -metadata flags can both preserve custom tags AND clear fields
// the user emptied.
final vorbisMap = <String, String>{
'TITLE': metadata['title'] ?? '',
'ARTIST': metadata['artist'] ?? '',
'ALBUM': metadata['album'] ?? '',
'ALBUMARTIST': metadata['album_artist'] ?? '',
'DATE': metadata['date'] ?? '',
'TRACKNUMBER':
(metadata['track_number']?.isNotEmpty == true &&
metadata['track_number'] != '0')
? (metadata['track_total']?.isNotEmpty == true &&
metadata['track_total'] != '0'
? '${metadata['track_number']}/${metadata['track_total']}'
: metadata['track_number']!)
: '',
'DISCNUMBER':
(metadata['disc_number']?.isNotEmpty == true &&
metadata['disc_number'] != '0')
? (metadata['disc_total']?.isNotEmpty == true &&
metadata['disc_total'] != '0'
? '${metadata['disc_number']}/${metadata['disc_total']}'
: metadata['disc_number']!)
: '',
'GENRE': metadata['genre'] ?? '',
'ISRC': metadata['isrc'] ?? '',
'LYRICS': metadata['lyrics'] ?? '',
'UNSYNCEDLYRICS': metadata['lyrics'] ?? '',
'ORGANIZATION': metadata['label'] ?? '',
'COPYRIGHT': metadata['copyright'] ?? '',
'COMPOSER': metadata['composer'] ?? '',
'COMMENT': metadata['comment'] ?? '',
};
try {
final existingMetadata = await PlatformBridge.readFileMetadata(
ffmpegTarget,
);
// Preserve ReplayGain tags if present — these are computed once
// during download and should survive manual metadata edits.
final rgFields = <String, String>{
'REPLAYGAIN_TRACK_GAIN':
existingMetadata['replaygain_track_gain']?.toString() ?? '',
'REPLAYGAIN_TRACK_PEAK':
existingMetadata['replaygain_track_peak']?.toString() ?? '',
'REPLAYGAIN_ALBUM_GAIN':
existingMetadata['replaygain_album_gain']?.toString() ?? '',
'REPLAYGAIN_ALBUM_PEAK':
existingMetadata['replaygain_album_peak']?.toString() ?? '',
};
rgFields.forEach((key, value) {
if (value.isNotEmpty) {
vorbisMap[key] = value;
}
});
} catch (_) {
// Lyrics/ReplayGain preservation is best-effort.
}
String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath;
String? extractedCoverPath;
if (existingCoverPath == null || existingCoverPath.isEmpty) {
// Preserve current embedded cover when user does not pick a new one.
try {
final tempDir = await Directory.systemTemp.createTemp('cover_');
final coverOutput =
'${tempDir.path}${Platform.pathSeparator}cover.jpg';
final coverResult = await PlatformBridge.extractCoverToFile(
ffmpegTarget,
coverOutput,
);
if (coverResult['error'] == null) {
existingCoverPath = coverOutput;
extractedCoverPath = coverOutput;
} else {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
}
} catch (_) {}
}
String? ffmpegResult;
if (isMp3) {
ffmpegResult = await FFmpegService.embedMetadataToMp3(
mp3Path: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
preserveMetadata: true,
);
} else if (isM4A) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
preserveMetadata: true,
);
} else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
artistTagMode: widget.artistTagMode,
preserveMetadata: true,
);
}
// Cleanup extracted temp cover (manual selected cover is cleaned on dispose)
if (extractedCoverPath != null && extractedCoverPath.isNotEmpty) {
final extractedFile = File(extractedCoverPath);
try {
await extractedFile.delete();
} catch (_) {}
try {
final dir = extractedFile.parent;
if (await dir.exists()) {
await dir.delete(recursive: true);
}
} catch (_) {}
}
if (ffmpegResult == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.metadataSaveFailedFfmpeg)),
);
}
setState(() => _saving = false);
return;
}
if (tempPath != null && safUri != null) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.metadataSaveFailedStorage)),
);
setState(() => _saving = false);
return;
}
}
}
if (mounted) {
Navigator.pop(context, true);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final cs = widget.colorScheme;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
Expanded(
child: Text(
context.l10n.trackEditMetadata,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (_saving)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
FilledButton(
onPressed: _save,
child: Text(context.l10n.dialogSave),
),
],
),
),
const SizedBox(height: 12),
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 24),
children: [
const SizedBox(height: 6),
_buildCoverEditor(cs),
_buildAutoFillSection(cs),
_field(context.l10n.editMetadataFieldTitle, _titleCtrl),
_field(context.l10n.editMetadataFieldArtist, _artistCtrl),
_field(context.l10n.editMetadataFieldAlbum, _albumCtrl),
_field(
context.l10n.editMetadataFieldAlbumArtist,
_albumArtistCtrl,
),
_field(
context.l10n.editMetadataFieldDate,
_dateCtrl,
hint: context.l10n.editMetadataFieldDateHint,
),
Row(
children: [
Expanded(
child: _field(
context.l10n.editMetadataFieldTrackNum,
_trackNumCtrl,
keyboard: TextInputType.number,
),
),
const SizedBox(width: 12),
Expanded(
child: _field(
context.l10n.editMetadataFieldTrackTotal,
_trackTotalCtrl,
keyboard: TextInputType.number,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _field(
context.l10n.editMetadataFieldDiscNum,
_discNumCtrl,
keyboard: TextInputType.number,
),
),
const SizedBox(width: 12),
Expanded(
child: _field(
context.l10n.editMetadataFieldDiscTotal,
_discTotalCtrl,
keyboard: TextInputType.number,
),
),
],
),
_field(context.l10n.editMetadataFieldGenre, _genreCtrl),
_field(context.l10n.editMetadataFieldIsrc, _isrcCtrl),
_field(
context.l10n.trackLyrics,
_lyricsCtrl,
maxLines: 8,
keyboard: TextInputType.multiline,
),
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: InkWell(
onTap: () =>
setState(() => _showAdvanced = !_showAdvanced),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
_showAdvanced
? Icons.expand_less
: Icons.expand_more,
size: 20,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
context.l10n.editMetadataAdvanced,
style: Theme.of(context).textTheme.labelLarge
?.copyWith(color: cs.onSurfaceVariant),
),
],
),
),
),
),
if (_showAdvanced) ...[
_field(context.l10n.editMetadataFieldLabel, _labelCtrl),
_field(
context.l10n.editMetadataFieldCopyright,
_copyrightCtrl,
),
_field(
context.l10n.editMetadataFieldComposer,
_composerCtrl,
),
_field(
context.l10n.editMetadataFieldComment,
_commentCtrl,
maxLines: 3,
),
],
const SizedBox(height: 24),
],
),
),
],
),
),
);
}
Widget _buildAutoFillSection(ColorScheme cs) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => setState(() => _showAutoFill = !_showAutoFill),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Row(
children: [
Icon(Icons.travel_explore, size: 20, color: cs.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.editMetadataAutoFill,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: cs.onSurface,
fontWeight: FontWeight.w600,
),
),
),
Icon(
_showAutoFill ? Icons.expand_less : Icons.expand_more,
size: 20,
color: cs.onSurfaceVariant,
),
],
),
),
),
if (_showAutoFill) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
context.l10n.editMetadataAutoFillDesc,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
_quickSelectButton(
label: context.l10n.editMetadataSelectAll,
onTap: _selectAllFields,
cs: cs,
),
const SizedBox(width: 8),
_quickSelectButton(
label: context.l10n.editMetadataSelectEmpty,
onTap: _selectEmptyFields,
cs: cs,
),
],
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(
spacing: 6,
runSpacing: 4,
children: _fieldDefs.keys.map((key) {
final selected = _autoFillFields.contains(key);
return FilterChip(
label: Text(_fieldLabel(key)),
selected: selected,
onSelected: _fetching
? null
: (val) {
setState(() {
if (val) {
_autoFillFields.add(key);
} else {
_autoFillFields.remove(key);
}
});
},
selectedColor: cs.primaryContainer,
checkmarkColor: cs.onPrimaryContainer,
labelStyle: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: selected
? cs.onPrimaryContainer
: cs.onSurfaceVariant,
),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}).toList(),
),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: (_fetching || _saving || _autoFillFields.isEmpty)
? null
: _fetchAndFill,
icon: _fetching
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.auto_fix_high),
label: Text(
_fetching
? context.l10n.editMetadataAutoFillSearching
: context.l10n.editMetadataAutoFillFetch,
),
),
),
),
],
],
),
),
);
}
Widget _quickSelectButton({
required String label,
required VoidCallback onTap,
required ColorScheme cs,
}) {
return InkWell(
onTap: _fetching ? null : onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: cs.outline.withValues(alpha: 0.5)),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: cs.primary),
),
),
);
}
Widget _buildCoverEditor(ColorScheme cs) {
final hasSelectedCover = _hasValue(_selectedCoverPath);
final hasCurrentCover = _hasValue(_currentCoverPath);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.editMetadataFieldCover,
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: cs.onSurface),
),
const SizedBox(height: 6),
if (_loadingCurrentCover)
const LinearProgressIndicator(minHeight: 2)
else if (!hasCurrentCover)
Text(
context.l10n.trackCoverNoEmbeddedArt,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _saving ? null : _pickCoverImage,
icon: const Icon(Icons.image_outlined),
label: Text(
hasSelectedCover
? context.l10n.trackCoverReplace
: context.l10n.trackCoverPick,
),
),
),
if (hasSelectedCover) ...[
const SizedBox(width: 8),
IconButton(
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 ??
context.l10n.trackCoverSelected,
),
),
],
),
if (hasSelectedCover) ...[
const SizedBox(height: 8),
Text(
context.l10n.trackCoverReplaceNotice,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
],
],
],
),
),
);
}
Widget _buildCoverPreviewTile({
required ColorScheme cs,
required String path,
required String label,
}) {
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,
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,
),
],
);
}
Widget _field(
String label,
TextEditingController controller, {
String? hint,
TextInputType? keyboard,
int maxLines = 1,
}) {
final cs = widget.colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
keyboardType: keyboard,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
hintText: hint,
filled: true,
fillColor: cs.surfaceContainerHighest.withValues(alpha: 0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: cs.outlineVariant.withValues(alpha: 0.5),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: cs.outlineVariant.withValues(alpha: 0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
),
);
}
}
class _MetadataItem {
final String label;
final String value;
_MetadataItem(this.label, this.value);
}