mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
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.
1712 lines
56 KiB
Dart
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);
|
|
}
|