mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
1254 lines
40 KiB
Dart
1254 lines
40 KiB
Dart
part of 'track_metadata_screen.dart';
|
|
|
|
extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
|
|
Widget _buildLyricsCard(BuildContext context, ColorScheme colorScheme) {
|
|
return Card(
|
|
elevation: 0,
|
|
color: settingsGroupColor(context),
|
|
shape: _sectionCardShape(colorScheme),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.lyrics_outlined,
|
|
size: 20,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
context.l10n.trackLyrics,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
if (_lyrics != null)
|
|
IconButton(
|
|
icon: const Icon(Icons.copy, size: 20),
|
|
onPressed: () => _copyToClipboard(context, _lyrics!),
|
|
tooltip: context.l10n.trackCopyLyrics,
|
|
),
|
|
],
|
|
),
|
|
if (_lyricsSource != null && _lyricsSource!.trim().isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
context.l10n.trackLyricsSource(_lyricsSource!),
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
if (_lyricsLoading)
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(20),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
)
|
|
else if (_lyricsError != null)
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.errorContainer.withValues(alpha: 0.3),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.error_outline,
|
|
color: colorScheme.error,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
_lyricsError!,
|
|
style: TextStyle(color: colorScheme.onErrorContainer),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: _fetchOnlineLyrics,
|
|
child: Text(context.l10n.dialogRetry),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else if (_isInstrumental)
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.music_note,
|
|
color: colorScheme.tertiary,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
context.l10n.trackInstrumental,
|
|
style: TextStyle(
|
|
color: colorScheme.onTertiaryContainer,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else if (_lyrics != null)
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
constraints: const BoxConstraints(maxHeight: 300),
|
|
child: SingleChildScrollView(
|
|
child: Text(
|
|
_lyrics!,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurface,
|
|
height: 1.6,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (!_lyricsEmbedded && _fileExists) ...[
|
|
const SizedBox(height: 16),
|
|
Center(
|
|
child: FilledButton.tonalIcon(
|
|
onPressed: _isEmbedding ? null : _embedLyrics,
|
|
icon: _isEmbedding
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
),
|
|
)
|
|
: const Icon(Icons.save_alt),
|
|
label: Text(context.l10n.trackEmbedLyrics),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
)
|
|
else if (_embeddedLyricsChecked && _fileExists)
|
|
Column(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest.withValues(
|
|
alpha: 0.5,
|
|
),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.lyrics_outlined,
|
|
color: colorScheme.onSurfaceVariant,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
context.l10n.trackLyricsNotInFile,
|
|
style: TextStyle(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Center(
|
|
child: FilledButton.tonalIcon(
|
|
onPressed: _fetchOnlineLyrics,
|
|
icon: const Icon(Icons.cloud_download_outlined),
|
|
label: Text(context.l10n.trackFetchOnlineLyrics),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
else
|
|
Center(
|
|
child: FilledButton.tonalIcon(
|
|
onPressed: _fetchLyrics,
|
|
icon: const Icon(Icons.download),
|
|
label: Text(context.l10n.trackLoadLyrics),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Check for lyrics embedded in the audio file only (no network requests).
|
|
/// Called automatically when the screen opens.
|
|
Future<void> _checkEmbeddedLyrics() async {
|
|
if (_lyricsLoading || !_fileExists) return;
|
|
final generation = _metadataLoadGeneration;
|
|
final sourcePath = cleanFilePath;
|
|
if (!mounted) return;
|
|
|
|
_setState(() {
|
|
_lyricsLoading = true;
|
|
_lyricsError = null;
|
|
_isInstrumental = false;
|
|
_lyricsSource = null;
|
|
});
|
|
|
|
try {
|
|
final embeddedResult =
|
|
await PlatformBridge.getLyricsLRCWithSource(
|
|
'',
|
|
trackName,
|
|
artistName,
|
|
filePath: sourcePath,
|
|
durationMs: 0,
|
|
).timeout(
|
|
const Duration(seconds: 5),
|
|
onTimeout: () => <String, dynamic>{'lyrics': '', 'source': ''},
|
|
);
|
|
|
|
final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? '';
|
|
final embeddedSource = embeddedResult['source']?.toString() ?? '';
|
|
|
|
if (mounted &&
|
|
generation == _metadataLoadGeneration &&
|
|
sourcePath == cleanFilePath) {
|
|
if (embeddedLyrics.isNotEmpty) {
|
|
final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics);
|
|
_setState(() {
|
|
_lyrics = cleanLyrics;
|
|
_rawLyrics = embeddedLyrics;
|
|
_lyricsSource = embeddedSource.isNotEmpty
|
|
? embeddedSource
|
|
: context.l10n.trackLyricsEmbeddedSource;
|
|
_lyricsEmbedded = true;
|
|
_lyricsLoading = false;
|
|
_embeddedLyricsChecked = true;
|
|
});
|
|
} else {
|
|
_setState(() {
|
|
_lyricsLoading = false;
|
|
_embeddedLyricsChecked = true;
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted &&
|
|
generation == _metadataLoadGeneration &&
|
|
sourcePath == cleanFilePath) {
|
|
_setState(() {
|
|
_lyricsLoading = false;
|
|
_embeddedLyricsChecked = true;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetch lyrics from online providers. Only called by user action.
|
|
Future<void> _fetchOnlineLyrics() async {
|
|
if (_lyricsLoading) return;
|
|
|
|
_setState(() {
|
|
_lyricsLoading = true;
|
|
_lyricsError = null;
|
|
_isInstrumental = false;
|
|
_lyricsSource = null;
|
|
});
|
|
|
|
try {
|
|
final durationMs = (duration ?? 0) * 1000;
|
|
|
|
final result = await PlatformBridge.getLyricsLRCWithSource(
|
|
_spotifyId ?? '',
|
|
trackName,
|
|
artistName,
|
|
filePath: null,
|
|
durationMs: durationMs,
|
|
).timeout(const Duration(seconds: 20));
|
|
|
|
final lrcText = result['lyrics']?.toString() ?? '';
|
|
final source = result['source']?.toString() ?? '';
|
|
final instrumental =
|
|
(result['instrumental'] as bool? ?? false) ||
|
|
lrcText == '[instrumental:true]';
|
|
|
|
if (mounted) {
|
|
if (instrumental) {
|
|
_setState(() {
|
|
_isInstrumental = true;
|
|
_lyricsSource = source.isNotEmpty ? source : null;
|
|
_lyricsLoading = false;
|
|
});
|
|
} else if (lrcText.isEmpty) {
|
|
_setState(() {
|
|
_lyricsError = context.l10n.trackLyricsNotAvailable;
|
|
_lyricsLoading = false;
|
|
});
|
|
} else {
|
|
final cleanLyrics = _cleanLrcForDisplay(lrcText);
|
|
_setState(() {
|
|
_lyrics = cleanLyrics;
|
|
_rawLyrics = lrcText;
|
|
_lyricsSource = source.isNotEmpty ? source : null;
|
|
_lyricsEmbedded = false;
|
|
_lyricsLoading = false;
|
|
});
|
|
}
|
|
}
|
|
} on TimeoutException {
|
|
if (mounted) {
|
|
_setState(() {
|
|
_lyricsError = context.l10n.trackLyricsTimeout;
|
|
_lyricsLoading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
_setState(() {
|
|
_lyricsError = context.l10n.trackLyricsLoadFailed;
|
|
_lyricsLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Full lyrics fetch: check embedded first, then online.
|
|
/// Used by the "Load Lyrics" button when file doesn't exist (non-local items).
|
|
Future<void> _fetchLyrics() async {
|
|
if (_lyricsLoading) return;
|
|
|
|
_setState(() {
|
|
_lyricsLoading = true;
|
|
_lyricsError = null;
|
|
_isInstrumental = false;
|
|
_lyricsSource = null;
|
|
});
|
|
|
|
try {
|
|
final durationMs = (duration ?? 0) * 1000;
|
|
|
|
if (_fileExists) {
|
|
final embeddedResult =
|
|
await PlatformBridge.getLyricsLRCWithSource(
|
|
'',
|
|
trackName,
|
|
artistName,
|
|
filePath: cleanFilePath,
|
|
durationMs: 0,
|
|
).timeout(
|
|
const Duration(seconds: 5),
|
|
onTimeout: () => <String, dynamic>{'lyrics': '', 'source': ''},
|
|
);
|
|
|
|
final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? '';
|
|
final embeddedSource = embeddedResult['source']?.toString() ?? '';
|
|
|
|
if (embeddedLyrics.isNotEmpty) {
|
|
if (mounted) {
|
|
final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics);
|
|
_setState(() {
|
|
_lyrics = cleanLyrics;
|
|
_rawLyrics = embeddedLyrics;
|
|
_lyricsSource = embeddedSource.isNotEmpty
|
|
? embeddedSource
|
|
: context.l10n.trackLyricsEmbeddedSource;
|
|
_lyricsEmbedded = true;
|
|
_lyricsLoading = false;
|
|
_embeddedLyricsChecked = true;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
final result = await PlatformBridge.getLyricsLRCWithSource(
|
|
_spotifyId ?? '',
|
|
trackName,
|
|
artistName,
|
|
filePath: null,
|
|
durationMs: durationMs,
|
|
).timeout(const Duration(seconds: 20));
|
|
|
|
final lrcText = result['lyrics']?.toString() ?? '';
|
|
final source = result['source']?.toString() ?? '';
|
|
final instrumental =
|
|
(result['instrumental'] as bool? ?? false) ||
|
|
lrcText == '[instrumental:true]';
|
|
|
|
if (mounted) {
|
|
if (instrumental) {
|
|
_setState(() {
|
|
_isInstrumental = true;
|
|
_lyricsSource = source.isNotEmpty ? source : null;
|
|
_lyricsLoading = false;
|
|
});
|
|
} else if (lrcText.isEmpty) {
|
|
_setState(() {
|
|
_lyricsError = context.l10n.trackLyricsNotAvailable;
|
|
_lyricsLoading = false;
|
|
});
|
|
} else {
|
|
final cleanLyrics = _cleanLrcForDisplay(lrcText);
|
|
_setState(() {
|
|
_lyrics = cleanLyrics;
|
|
_rawLyrics = lrcText;
|
|
_lyricsSource = source.isNotEmpty ? source : null;
|
|
_lyricsEmbedded = false;
|
|
_lyricsLoading = false;
|
|
});
|
|
}
|
|
}
|
|
} on TimeoutException {
|
|
if (mounted) {
|
|
_setState(() {
|
|
_lyricsError = context.l10n.trackLyricsTimeout;
|
|
_lyricsLoading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
_setState(() {
|
|
_lyricsError = context.l10n.trackLyricsLoadFailed;
|
|
_lyricsLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _embedLyrics() async {
|
|
if (_isEmbedding || _rawLyrics == null || !_fileExists) return;
|
|
|
|
_setState(() => _isEmbedding = true);
|
|
|
|
final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage;
|
|
final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics;
|
|
final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat;
|
|
|
|
String? safTempPath;
|
|
String? coverPath;
|
|
|
|
try {
|
|
final rawLyrics = _rawLyrics!;
|
|
var workingPath = cleanFilePath;
|
|
|
|
if (_isSafFile) {
|
|
safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath);
|
|
if (safTempPath == null || safTempPath.isEmpty) {
|
|
throw Exception('Failed to access SAF file');
|
|
}
|
|
workingPath = safTempPath;
|
|
}
|
|
|
|
final lower = workingPath.toLowerCase();
|
|
final isFlac = lower.endsWith('.flac');
|
|
final isMp3 = lower.endsWith('.mp3');
|
|
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
|
|
final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac');
|
|
|
|
bool success = false;
|
|
String? error;
|
|
|
|
if (isFlac) {
|
|
final result = await PlatformBridge.embedLyricsToFile(
|
|
workingPath,
|
|
rawLyrics,
|
|
);
|
|
if (result['success'] == true) {
|
|
if (_isSafFile) {
|
|
final ok = await PlatformBridge.writeTempToSaf(
|
|
workingPath,
|
|
cleanFilePath,
|
|
);
|
|
success = ok;
|
|
if (!ok) {
|
|
error = l10nFailedToWriteStorage;
|
|
}
|
|
} else {
|
|
success = true;
|
|
}
|
|
} else {
|
|
error = userFacingErrorMessage(
|
|
result['error'],
|
|
fallback: l10nFailedToEmbedLyrics,
|
|
);
|
|
}
|
|
} else if (isMp3 || isOpus || isM4A) {
|
|
final metadata = _buildFallbackMetadata();
|
|
try {
|
|
final result = await PlatformBridge.readFileMetadata(workingPath);
|
|
if (result['error'] == null) {
|
|
final mapped = _mapMetadataForTagEmbed(result);
|
|
metadata.addAll(mapped);
|
|
}
|
|
} catch (e) {
|
|
_log.w('Failed reading file metadata before lyrics embed: $e');
|
|
}
|
|
|
|
metadata['LYRICS'] = rawLyrics;
|
|
metadata['UNSYNCEDLYRICS'] = rawLyrics;
|
|
|
|
try {
|
|
final tempDir = await getTemporaryDirectory();
|
|
final coverOutput =
|
|
'${tempDir.path}${Platform.pathSeparator}lyrics_cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
|
final coverResult = await PlatformBridge.extractCoverToFile(
|
|
workingPath,
|
|
coverOutput,
|
|
);
|
|
if (coverResult['error'] == null) {
|
|
coverPath = coverOutput;
|
|
}
|
|
} catch (_) {}
|
|
|
|
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
|
String? ffmpegResult;
|
|
if (isMp3) {
|
|
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
|
mp3Path: workingPath,
|
|
coverPath: coverPath,
|
|
metadata: metadata,
|
|
);
|
|
} else if (isM4A) {
|
|
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
|
m4aPath: workingPath,
|
|
coverPath: coverPath,
|
|
metadata: metadata,
|
|
);
|
|
} else {
|
|
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
|
opusPath: workingPath,
|
|
coverPath: coverPath,
|
|
metadata: metadata,
|
|
artistTagMode: artistTagMode,
|
|
);
|
|
}
|
|
|
|
if (ffmpegResult == null) {
|
|
error = l10nFailedToEmbedLyrics;
|
|
} else if (_isSafFile) {
|
|
final ok = await PlatformBridge.writeTempToSaf(
|
|
ffmpegResult,
|
|
cleanFilePath,
|
|
);
|
|
success = ok;
|
|
if (!ok) {
|
|
error = l10nFailedToWriteStorage;
|
|
}
|
|
} else {
|
|
success = true;
|
|
}
|
|
} else {
|
|
error = l10nUnsupportedFormat;
|
|
}
|
|
|
|
if (mounted) {
|
|
if (success) {
|
|
_setState(() {
|
|
_lyricsEmbedded = true;
|
|
_isEmbedding = false;
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackLyricsEmbedded)),
|
|
);
|
|
} else {
|
|
_setState(() => _isEmbedding = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(error ?? context.l10n.snackbarFailedToEmbedLyrics),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
_setState(() => _isEmbedding = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.snackbarError(context.friendlyError(e))),
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (coverPath != null) {
|
|
try {
|
|
await File(coverPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
if (safTempPath != null) {
|
|
try {
|
|
await File(safTempPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
String _sanitizeFileNameSegment(String value) {
|
|
var sanitized = value
|
|
.replaceAll(_TrackMetadataScreenState._invalidFileNameChars, '_')
|
|
.trim();
|
|
sanitized = sanitized.replaceAll(
|
|
_TrackMetadataScreenState._leadingOrTrailingDots,
|
|
'',
|
|
);
|
|
sanitized = sanitized.replaceAll(
|
|
_TrackMetadataScreenState._multiUnderscore,
|
|
'_',
|
|
);
|
|
if (sanitized.isEmpty) {
|
|
return 'untitled';
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
String _buildSaveBaseName() {
|
|
final artist = _sanitizeFileNameSegment(artistName);
|
|
final track = _sanitizeFileNameSegment(trackName);
|
|
return '$artist - $track';
|
|
}
|
|
|
|
String _getFileDirectory() {
|
|
if (isContentUri(cleanFilePath)) {
|
|
// SAF URIs don't have a filesystem parent directory
|
|
return '';
|
|
}
|
|
final file = File(cleanFilePath);
|
|
return file.parent.path;
|
|
}
|
|
|
|
bool get _isSafFile => isContentUri(cleanFilePath);
|
|
|
|
Future<void> _saveCoverArt() async {
|
|
try {
|
|
final baseName = _buildSaveBaseName();
|
|
|
|
if (_isSafFile) {
|
|
final tempDir = await Directory.systemTemp.createTemp('cover_');
|
|
final tempOutput =
|
|
'${tempDir.path}${Platform.pathSeparator}$baseName.jpg';
|
|
|
|
Map<String, dynamic> result;
|
|
if (_fileExists) {
|
|
// Prefer extracting cover from the already-downloaded file to avoid
|
|
// a redundant network request.
|
|
result = await PlatformBridge.extractCoverToFile(
|
|
cleanFilePath,
|
|
tempOutput,
|
|
);
|
|
if (result['error'] != null &&
|
|
_coverUrl != null &&
|
|
_coverUrl!.isNotEmpty) {
|
|
result = await PlatformBridge.downloadCoverToFile(
|
|
_coverUrl!,
|
|
tempOutput,
|
|
maxQuality: true,
|
|
);
|
|
}
|
|
} else if (_coverUrl != null && _coverUrl!.isNotEmpty) {
|
|
result = await PlatformBridge.downloadCoverToFile(
|
|
_coverUrl!,
|
|
tempOutput,
|
|
maxQuality: true,
|
|
);
|
|
} else {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackCoverNoSource)),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (result['error'] != null) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.trackSaveFailed(
|
|
context.friendlyError(result['error']),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
try {
|
|
await Directory(tempDir.path).delete(recursive: true);
|
|
} catch (_) {}
|
|
return;
|
|
}
|
|
|
|
final treeUri = _downloadItem?.downloadTreeUri;
|
|
final relativeDir = _downloadItem?.safRelativeDir ?? '';
|
|
if (treeUri != null && treeUri.isNotEmpty) {
|
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
fileName: '$baseName.jpg',
|
|
mimeType: 'image/jpeg',
|
|
srcPath: tempOutput,
|
|
);
|
|
try {
|
|
await Directory(tempDir.path).delete(recursive: true);
|
|
} catch (_) {}
|
|
if (mounted) {
|
|
if (safUri != null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackCoverSaved(baseName))),
|
|
);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.trackSaveFailed('Failed to write to storage'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
await Directory(tempDir.path).delete(recursive: true);
|
|
} catch (_) {}
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.trackSaveFailed('No storage access'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
final dir = _getFileDirectory();
|
|
final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg';
|
|
|
|
Map<String, dynamic> result;
|
|
if (_fileExists) {
|
|
result = await PlatformBridge.extractCoverToFile(
|
|
cleanFilePath,
|
|
outputPath,
|
|
);
|
|
if (result['error'] != null &&
|
|
_coverUrl != null &&
|
|
_coverUrl!.isNotEmpty) {
|
|
result = await PlatformBridge.downloadCoverToFile(
|
|
_coverUrl!,
|
|
outputPath,
|
|
maxQuality: true,
|
|
);
|
|
}
|
|
} else if (_coverUrl != null && _coverUrl!.isNotEmpty) {
|
|
result = await PlatformBridge.downloadCoverToFile(
|
|
_coverUrl!,
|
|
outputPath,
|
|
maxQuality: true,
|
|
);
|
|
} else {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackCoverNoSource)),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (mounted) {
|
|
if (result['error'] != null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.trackSaveFailed(
|
|
context.friendlyError(result['error']),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackCoverSaved(baseName))),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.trackSaveFailed(context.friendlyError(e)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _saveLyrics() async {
|
|
try {
|
|
final baseName = _buildSaveBaseName();
|
|
final durationMs = (duration ?? 0) * 1000;
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackSaveLyricsProgress)),
|
|
);
|
|
}
|
|
|
|
if (_isSafFile) {
|
|
final tempDir = await Directory.systemTemp.createTemp('lyrics_');
|
|
final tempOutput =
|
|
'${tempDir.path}${Platform.pathSeparator}$baseName.lrc';
|
|
|
|
final result = await PlatformBridge.fetchAndSaveLyrics(
|
|
trackName: trackName,
|
|
artistName: artistName,
|
|
spotifyId: _spotifyId ?? '',
|
|
durationMs: durationMs,
|
|
outputPath: tempOutput,
|
|
audioFilePath: _fileExists ? cleanFilePath : '',
|
|
);
|
|
|
|
if (result['error'] != null) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.trackSaveFailed(
|
|
context.friendlyError(result['error']),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
try {
|
|
await Directory(tempDir.path).delete(recursive: true);
|
|
} catch (_) {}
|
|
return;
|
|
}
|
|
|
|
final treeUri = _downloadItem?.downloadTreeUri;
|
|
final relativeDir = _downloadItem?.safRelativeDir ?? '';
|
|
if (treeUri != null && treeUri.isNotEmpty) {
|
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
fileName: '$baseName.lrc',
|
|
mimeType: 'application/octet-stream',
|
|
srcPath: tempOutput,
|
|
);
|
|
try {
|
|
await Directory(tempDir.path).delete(recursive: true);
|
|
} catch (_) {}
|
|
if (mounted) {
|
|
if (safUri != null) {
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.trackLyricsSaved(baseName)),
|
|
),
|
|
);
|
|
} else {
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.trackSaveFailed(
|
|
'Failed to write to storage',
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
await Directory(tempDir.path).delete(recursive: true);
|
|
} catch (_) {}
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.trackSaveFailed('No storage access'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
final dir = _getFileDirectory();
|
|
final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc';
|
|
|
|
final result = await PlatformBridge.fetchAndSaveLyrics(
|
|
trackName: trackName,
|
|
artistName: artistName,
|
|
spotifyId: _spotifyId ?? '',
|
|
durationMs: durationMs,
|
|
outputPath: outputPath,
|
|
audioFilePath: _fileExists ? cleanFilePath : '',
|
|
);
|
|
|
|
if (mounted) {
|
|
if (result['error'] != null) {
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.trackSaveFailed(
|
|
context.friendlyError(result['error']),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackLyricsSaved(baseName))),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.trackSaveFailed(context.friendlyError(e)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _reEnrichMetadata() async {
|
|
if (!_fileExists) return;
|
|
|
|
try {
|
|
final settings = ref.read(settingsProvider);
|
|
final artistTagMode = settings.artistTagMode;
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final l10n = context.l10n;
|
|
await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend();
|
|
if (!mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.trackReEnrichSearching)),
|
|
);
|
|
|
|
final durationMs = (duration ?? 0) * 1000;
|
|
final request = <String, dynamic>{
|
|
'file_path': cleanFilePath,
|
|
'cover_url': _coverUrl ?? '',
|
|
'max_quality': true,
|
|
'embed_lyrics': settings.embedLyrics,
|
|
'lyrics_mode': settings.lyricsMode,
|
|
'artist_tag_mode': artistTagMode,
|
|
'spotify_id': _spotifyId ?? '',
|
|
'track_name': trackName,
|
|
'artist_name': artistName,
|
|
'album_name': albumName,
|
|
'album_artist': albumArtist ?? '',
|
|
'track_number': trackNumber ?? 0,
|
|
'total_tracks': totalTracks ?? 0,
|
|
'disc_number': discNumber ?? 0,
|
|
'total_discs': totalDiscs ?? 0,
|
|
'release_date': releaseDate ?? '',
|
|
'isrc': isrc ?? '',
|
|
'genre': genre ?? '',
|
|
'label': label ?? '',
|
|
'copyright': copyright ?? '',
|
|
'composer': composer ?? '',
|
|
'duration_ms': durationMs,
|
|
'search_online': true,
|
|
'replace_release_metadata': allowsReleaseIdentityReplacement(
|
|
ReEnrichOperationScope.singleFile,
|
|
),
|
|
};
|
|
|
|
final result = await PlatformBridge.reEnrichFile(request);
|
|
final method = result['method'] as String?;
|
|
|
|
final enriched = result['enriched_metadata'] as Map<String, dynamic>?;
|
|
if (enriched != null && mounted) {
|
|
_setState(() {
|
|
_editedMetadata = {
|
|
'title': enriched['track_name'] ?? trackName,
|
|
'artist': enriched['artist_name'] ?? artistName,
|
|
'album': enriched['album_name'] ?? albumName,
|
|
'album_artist': enriched['album_artist'] ?? albumArtist,
|
|
'date': enriched['release_date'] ?? releaseDate,
|
|
'track_number': enriched['track_number'] ?? trackNumber,
|
|
'total_tracks': enriched['total_tracks'] ?? totalTracks,
|
|
'disc_number': enriched['disc_number'] ?? discNumber,
|
|
'total_discs': enriched['total_discs'] ?? totalDiscs,
|
|
'isrc': enriched['isrc'] ?? isrc,
|
|
'genre': enriched['genre'] ?? genre,
|
|
'label': enriched['label'] ?? label,
|
|
'copyright': enriched['copyright'] ?? copyright,
|
|
'composer': enriched['composer'] ?? composer,
|
|
};
|
|
});
|
|
}
|
|
|
|
if (method == 'native') {
|
|
// FLAC - handled natively by Go (SAF write-back handled in Kotlin).
|
|
// External .lrc sidecar for filesystem files is written here; SAF
|
|
// sidecars are created natively in the Kotlin reEnrichFile handler.
|
|
await writeReEnrichSidecarLrc(
|
|
audioFilePath: cleanFilePath,
|
|
reEnrichResult: result,
|
|
);
|
|
await _refreshEmbeddedCoverPreview(force: true);
|
|
_markMetadataChanged();
|
|
await _syncDownloadHistoryMetadata();
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackReEnrichSuccess)),
|
|
);
|
|
}
|
|
} else if (method == 'ffmpeg') {
|
|
// MP3/Opus - need FFmpeg from Dart side
|
|
// 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 ?? cleanFilePath;
|
|
|
|
final downloadedCoverPath = result['cover_path'] as String?;
|
|
String? effectiveCoverPath = downloadedCoverPath;
|
|
String? extractedCoverPath;
|
|
try {
|
|
if (!_hasPath(effectiveCoverPath)) {
|
|
try {
|
|
final tempDir = await Directory.systemTemp.createTemp(
|
|
'reenrich_cover_',
|
|
);
|
|
final coverOutput =
|
|
'${tempDir.path}${Platform.pathSeparator}cover.jpg';
|
|
final extracted = await PlatformBridge.extractCoverToFile(
|
|
ffmpegTarget,
|
|
coverOutput,
|
|
);
|
|
if (extracted['error'] == null) {
|
|
effectiveCoverPath = coverOutput;
|
|
extractedCoverPath = coverOutput;
|
|
} else {
|
|
try {
|
|
await tempDir.delete(recursive: true);
|
|
} catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
final metadata = (result['metadata'] as Map<String, dynamic>?)?.map(
|
|
(k, v) => MapEntry(k, v.toString()),
|
|
);
|
|
final lower = cleanFilePath.toLowerCase();
|
|
|
|
String? ffmpegResult;
|
|
if (lower.endsWith('.mp3')) {
|
|
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
|
mp3Path: ffmpegTarget,
|
|
coverPath: effectiveCoverPath,
|
|
metadata: metadata,
|
|
);
|
|
} else if (lower.endsWith('.m4a') || lower.endsWith('.aac')) {
|
|
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
|
m4aPath: ffmpegTarget,
|
|
coverPath: effectiveCoverPath,
|
|
metadata: metadata,
|
|
);
|
|
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
|
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
|
opusPath: ffmpegTarget,
|
|
coverPath: effectiveCoverPath,
|
|
metadata: metadata,
|
|
artistTagMode: artistTagMode,
|
|
);
|
|
}
|
|
|
|
if (ffmpegResult != null && tempPath != null && safUri != null) {
|
|
final ok = await PlatformBridge.writeTempToSaf(
|
|
ffmpegResult,
|
|
safUri,
|
|
);
|
|
if (!ok && mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.trackSaveFailed(
|
|
context.l10n.snackbarFailedToWriteStorage,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
await writeReEnrichSafSidecarLrc(
|
|
safUri: safUri,
|
|
reEnrichResult: result,
|
|
);
|
|
}
|
|
|
|
if (ffmpegResult != null) {
|
|
// Filesystem .lrc sidecar. SAF sidecar is written only after
|
|
// writeTempToSaf succeeds.
|
|
await writeReEnrichSidecarLrc(
|
|
audioFilePath: cleanFilePath,
|
|
reEnrichResult: result,
|
|
);
|
|
await _refreshEmbeddedCoverPreview(force: true);
|
|
_markMetadataChanged();
|
|
await _syncDownloadHistoryMetadata();
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackReEnrichSuccess)),
|
|
);
|
|
}
|
|
} else if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackReEnrichFfmpegFailed)),
|
|
);
|
|
}
|
|
} finally {
|
|
for (final path in [downloadedCoverPath, tempPath]) {
|
|
if (!_hasPath(path)) continue;
|
|
try {
|
|
await File(path!).delete();
|
|
} catch (_) {}
|
|
}
|
|
if (_hasPath(extractedCoverPath)) {
|
|
await _cleanupTempFileAndParent(extractedCoverPath);
|
|
}
|
|
}
|
|
} else {
|
|
if (mounted) {
|
|
final error = context.friendlyError(
|
|
result['error'],
|
|
fallback: context.l10n.metadataSaveFailedFfmpeg,
|
|
);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.trackSaveFailed(error))),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.trackSaveFailed(context.friendlyError(e)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _syncDownloadHistoryMetadata() async {
|
|
if (_isLocalItem || _downloadItem == null) return;
|
|
|
|
String? normalizedOrNull(String? value) {
|
|
if (value == null) return null;
|
|
final trimmed = value.trim();
|
|
if (trimmed.isEmpty) return null;
|
|
return trimmed;
|
|
}
|
|
|
|
try {
|
|
await ref
|
|
.read(downloadHistoryProvider.notifier)
|
|
.updateMetadataForItem(
|
|
id: _downloadItem!.id,
|
|
trackName: trackName,
|
|
artistName: artistName,
|
|
albumName: albumName,
|
|
albumArtist: normalizedOrNull(albumArtist),
|
|
isrc: normalizedOrNull(isrc),
|
|
trackNumber: trackNumber,
|
|
totalTracks: totalTracks,
|
|
discNumber: discNumber,
|
|
totalDiscs: totalDiscs,
|
|
releaseDate: normalizedOrNull(releaseDate),
|
|
genre: normalizedOrNull(genre),
|
|
composer: normalizedOrNull(composer),
|
|
label: normalizedOrNull(label),
|
|
copyright: normalizedOrNull(copyright),
|
|
);
|
|
} catch (e) {
|
|
_log.w('Failed to sync download history metadata: $e');
|
|
}
|
|
}
|
|
|
|
String _cleanLrcForDisplay(String lrc) {
|
|
final lines = lrc.split('\n');
|
|
final cleanLines = <String>[];
|
|
|
|
for (final line in lines) {
|
|
var cleaned = line.trim();
|
|
|
|
if (_TrackMetadataScreenState._lrcMetadataPattern.hasMatch(cleaned) &&
|
|
!_TrackMetadataScreenState._lrcBackgroundLinePattern.hasMatch(
|
|
cleaned,
|
|
)) {
|
|
continue;
|
|
}
|
|
|
|
// Convert [bg:...] wrapper to a plain secondary vocal line.
|
|
final bgMatch = _TrackMetadataScreenState._lrcBackgroundLinePattern
|
|
.firstMatch(cleaned);
|
|
if (bgMatch != null) {
|
|
cleaned = bgMatch.group(1)?.trim() ?? '';
|
|
}
|
|
|
|
cleaned = cleaned
|
|
.replaceAll(_TrackMetadataScreenState._lrcTimestampPattern, '')
|
|
.trim();
|
|
cleaned = cleaned.replaceAll(
|
|
_TrackMetadataScreenState._lrcInlineTimestampPattern,
|
|
'',
|
|
);
|
|
cleaned = cleaned.replaceFirst(
|
|
_TrackMetadataScreenState._lrcSpeakerPrefixPattern,
|
|
'',
|
|
);
|
|
cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim();
|
|
|
|
if (cleaned.isNotEmpty) {
|
|
cleanLines.add(cleaned);
|
|
}
|
|
}
|
|
|
|
return cleanLines.join('\n');
|
|
}
|
|
}
|