mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-20 18:07:44 +02:00
feat: save cover art, save lyrics, re-enrich metadata with full SAF support + YouTube Cobalt provider with SpotubeDL fallback + metadata summary logging
This commit is contained in:
@@ -5037,6 +5037,102 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Refresh stats'**
|
||||
String get cacheRefreshStats;
|
||||
|
||||
/// Menu action - save album cover art as file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save Cover Art'**
|
||||
String get trackSaveCoverArt;
|
||||
|
||||
/// Subtitle for save cover art action
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save album art as .jpg file'**
|
||||
String get trackSaveCoverArtSubtitle;
|
||||
|
||||
/// Menu action - save lyrics as .lrc file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save Lyrics (.lrc)'**
|
||||
String get trackSaveLyrics;
|
||||
|
||||
/// Subtitle for save lyrics action
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetch and save lyrics as .lrc file'**
|
||||
String get trackSaveLyricsSubtitle;
|
||||
|
||||
/// Menu action - re-embed metadata into audio file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Re-enrich Metadata'**
|
||||
String get trackReEnrich;
|
||||
|
||||
/// Subtitle for re-enrich metadata action
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Re-embed metadata without re-downloading'**
|
||||
String get trackReEnrichSubtitle;
|
||||
|
||||
/// Subtitle for re-enrich metadata action for local items
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search metadata online and embed into file'**
|
||||
String get trackReEnrichOnlineSubtitle;
|
||||
|
||||
/// Menu action - edit embedded metadata
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit Metadata'**
|
||||
String get trackEditMetadata;
|
||||
|
||||
/// Snackbar after cover art saved
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cover art saved to {fileName}'**
|
||||
String trackCoverSaved(String fileName);
|
||||
|
||||
/// Snackbar when no cover art URL or embedded cover
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No cover art source available'**
|
||||
String get trackCoverNoSource;
|
||||
|
||||
/// Snackbar after lyrics saved
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lyrics saved to {fileName}'**
|
||||
String trackLyricsSaved(String fileName);
|
||||
|
||||
/// Snackbar while re-enriching metadata
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Re-enriching metadata...'**
|
||||
String get trackReEnrichProgress;
|
||||
|
||||
/// Snackbar while searching metadata from internet for local items
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Searching metadata online...'**
|
||||
String get trackReEnrichSearching;
|
||||
|
||||
/// Snackbar after successful re-enrichment
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Metadata re-enriched successfully'**
|
||||
String get trackReEnrichSuccess;
|
||||
|
||||
/// Snackbar when FFmpeg embed fails for MP3/Opus
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'FFmpeg metadata embed failed'**
|
||||
String get trackReEnrichFfmpegFailed;
|
||||
|
||||
/// Snackbar when save operation fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed: {error}'**
|
||||
String trackSaveFailed(String error);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -2848,4 +2848,60 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2833,4 +2833,60 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2833,6 +2833,62 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||
|
||||
@@ -2833,4 +2833,60 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2833,4 +2833,60 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2849,4 +2849,63 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Segarkan statistik';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Simpan Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle =>
|
||||
'Simpan cover album sebagai file .jpg';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Simpan Lirik (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle =>
|
||||
'Ambil dan simpan lirik sebagai file .lrc';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Perkaya Ulang Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Tanamkan ulang metadata tanpa mengunduh ulang';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Cari metadata dari internet dan tanamkan ke file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art disimpan ke $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'Tidak ada sumber cover art';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lirik disimpan ke $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Memperkaya ulang metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Mencari metadata dari internet...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata berhasil diperkaya ulang';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed =>
|
||||
'Gagal menanamkan metadata via FFmpeg';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Gagal: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2819,4 +2819,60 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2833,4 +2833,60 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2833,4 +2833,60 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2833,6 +2833,62 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||
|
||||
@@ -2879,4 +2879,60 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2848,4 +2848,60 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2833,6 +2833,62 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||
|
||||
+49
-1
@@ -2134,5 +2134,53 @@
|
||||
}
|
||||
},
|
||||
"cacheRefreshStats": "Refresh stats",
|
||||
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"}
|
||||
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"},
|
||||
|
||||
"trackSaveCoverArt": "Save Cover Art",
|
||||
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
|
||||
"trackSaveCoverArtSubtitle": "Save album art as .jpg file",
|
||||
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
|
||||
"trackSaveLyrics": "Save Lyrics (.lrc)",
|
||||
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
||||
"trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file",
|
||||
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
||||
"trackReEnrich": "Re-enrich Metadata",
|
||||
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
|
||||
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
|
||||
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
|
||||
"trackReEnrichOnlineSubtitle": "Search metadata online and embed into file",
|
||||
"@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"},
|
||||
"trackEditMetadata": "Edit Metadata",
|
||||
"@trackEditMetadata": {"description": "Menu action - edit embedded metadata"},
|
||||
"trackCoverSaved": "Cover art saved to {fileName}",
|
||||
"@trackCoverSaved": {
|
||||
"description": "Snackbar after cover art saved",
|
||||
"placeholders": {
|
||||
"fileName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"trackCoverNoSource": "No cover art source available",
|
||||
"@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"},
|
||||
"trackLyricsSaved": "Lyrics saved to {fileName}",
|
||||
"@trackLyricsSaved": {
|
||||
"description": "Snackbar after lyrics saved",
|
||||
"placeholders": {
|
||||
"fileName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"trackReEnrichProgress": "Re-enriching metadata...",
|
||||
"@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"},
|
||||
"trackReEnrichSearching": "Searching metadata online...",
|
||||
"@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"},
|
||||
"trackReEnrichSuccess": "Metadata re-enriched successfully",
|
||||
"@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"},
|
||||
"trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed",
|
||||
"@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"},
|
||||
"trackSaveFailed": "Failed: {error}",
|
||||
"@trackSaveFailed": {
|
||||
"description": "Snackbar when save operation fails",
|
||||
"placeholders": {
|
||||
"error": {"type": "String"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+49
-1
@@ -3154,5 +3154,53 @@
|
||||
}
|
||||
},
|
||||
"cacheRefreshStats": "Segarkan statistik",
|
||||
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"}
|
||||
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"},
|
||||
|
||||
"trackSaveCoverArt": "Simpan Cover Art",
|
||||
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
|
||||
"trackSaveCoverArtSubtitle": "Simpan cover album sebagai file .jpg",
|
||||
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
|
||||
"trackSaveLyrics": "Simpan Lirik (.lrc)",
|
||||
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
||||
"trackSaveLyricsSubtitle": "Ambil dan simpan lirik sebagai file .lrc",
|
||||
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
||||
"trackReEnrich": "Perkaya Ulang Metadata",
|
||||
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
|
||||
"trackReEnrichSubtitle": "Tanamkan ulang metadata tanpa mengunduh ulang",
|
||||
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
|
||||
"trackReEnrichOnlineSubtitle": "Cari metadata dari internet dan tanamkan ke file",
|
||||
"@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"},
|
||||
"trackEditMetadata": "Edit Metadata",
|
||||
"@trackEditMetadata": {"description": "Menu action - edit embedded metadata"},
|
||||
"trackCoverSaved": "Cover art disimpan ke {fileName}",
|
||||
"@trackCoverSaved": {
|
||||
"description": "Snackbar after cover art saved",
|
||||
"placeholders": {
|
||||
"fileName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"trackCoverNoSource": "Tidak ada sumber cover art",
|
||||
"@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"},
|
||||
"trackLyricsSaved": "Lirik disimpan ke {fileName}",
|
||||
"@trackLyricsSaved": {
|
||||
"description": "Snackbar after lyrics saved",
|
||||
"placeholders": {
|
||||
"fileName": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"trackReEnrichProgress": "Memperkaya ulang metadata...",
|
||||
"@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"},
|
||||
"trackReEnrichSearching": "Mencari metadata dari internet...",
|
||||
"@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"},
|
||||
"trackReEnrichSuccess": "Metadata berhasil diperkaya ulang",
|
||||
"@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"},
|
||||
"trackReEnrichFfmpegFailed": "Gagal menanamkan metadata via FFmpeg",
|
||||
"@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"},
|
||||
"trackSaveFailed": "Gagal: {error}",
|
||||
"@trackSaveFailed": {
|
||||
"description": "Snackbar when save operation fails",
|
||||
"placeholders": {
|
||||
"error": {"type": "String"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1253,6 +1253,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
static final _isrcRegex = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$');
|
||||
|
||||
bool _isValidISRC(String value) {
|
||||
return _isrcRegex.hasMatch(value.toUpperCase());
|
||||
}
|
||||
|
||||
void updateSettings(AppSettings settings) {
|
||||
final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5);
|
||||
state = state.copyWith(
|
||||
@@ -2607,7 +2613,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
if (deezerTrackId == null &&
|
||||
trackToDownload.isrc != null &&
|
||||
trackToDownload.isrc!.isNotEmpty) {
|
||||
trackToDownload.isrc!.isNotEmpty &&
|
||||
_isValidISRC(trackToDownload.isrc!)) {
|
||||
try {
|
||||
_log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}');
|
||||
final deezerResult = await PlatformBridge.searchDeezerByISRC(
|
||||
@@ -2623,6 +2630,75 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Use SongLink to convert Spotify ID to Deezer ID
|
||||
if (deezerTrackId == null &&
|
||||
trackToDownload.id.isNotEmpty &&
|
||||
!trackToDownload.id.startsWith('deezer:') &&
|
||||
!trackToDownload.id.startsWith('extension:')) {
|
||||
try {
|
||||
// Extract clean Spotify ID (remove spotify: prefix if present)
|
||||
String spotifyId = trackToDownload.id;
|
||||
if (spotifyId.startsWith('spotify:track:')) {
|
||||
spotifyId = spotifyId.split(':').last;
|
||||
}
|
||||
_log.d('No Deezer ID, converting from Spotify via SongLink: $spotifyId');
|
||||
final deezerData = await PlatformBridge.convertSpotifyToDeezer('track', spotifyId);
|
||||
// Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}}
|
||||
final trackData = deezerData['track'];
|
||||
if (trackData is Map<String, dynamic>) {
|
||||
final rawId = trackData['spotify_id'] as String?;
|
||||
if (rawId != null && rawId.startsWith('deezer:')) {
|
||||
deezerTrackId = rawId.split(':')[1];
|
||||
_log.d('Found Deezer track ID via SongLink: $deezerTrackId');
|
||||
} else if (deezerData['id'] != null) {
|
||||
deezerTrackId = deezerData['id'].toString();
|
||||
_log.d('Found Deezer track ID via SongLink (legacy): $deezerTrackId');
|
||||
}
|
||||
|
||||
// Enrich track metadata from Deezer response (release_date, isrc, etc.)
|
||||
final deezerReleaseDate = _normalizeOptionalString(trackData['release_date'] as String?);
|
||||
final deezerIsrc = _normalizeOptionalString(trackData['isrc'] as String?);
|
||||
final deezerTrackNum = trackData['track_number'] as int?;
|
||||
final deezerDiscNum = trackData['disc_number'] as int?;
|
||||
|
||||
final needsEnrich =
|
||||
(trackToDownload.releaseDate == null && deezerReleaseDate != null) ||
|
||||
(trackToDownload.isrc == null && deezerIsrc != null) ||
|
||||
(!_isValidISRC(trackToDownload.isrc ?? '') && deezerIsrc != null) ||
|
||||
(trackToDownload.trackNumber == null && deezerTrackNum != null) ||
|
||||
(trackToDownload.discNumber == null && deezerDiscNum != null);
|
||||
|
||||
if (needsEnrich) {
|
||||
trackToDownload = Track(
|
||||
id: trackToDownload.id,
|
||||
name: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
duration: trackToDownload.duration,
|
||||
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
|
||||
? deezerIsrc
|
||||
: trackToDownload.isrc,
|
||||
trackNumber: trackToDownload.trackNumber ?? deezerTrackNum,
|
||||
discNumber: trackToDownload.discNumber ?? deezerDiscNum,
|
||||
releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate,
|
||||
deezerId: deezerTrackId,
|
||||
availability: trackToDownload.availability,
|
||||
albumType: trackToDownload.albumType,
|
||||
source: trackToDownload.source,
|
||||
);
|
||||
_log.d('Enriched track from Deezer - date: ${trackToDownload.releaseDate}, ISRC: ${trackToDownload.isrc}, track: ${trackToDownload.trackNumber}, disc: ${trackToDownload.discNumber}');
|
||||
}
|
||||
} else if (deezerData['id'] != null) {
|
||||
deezerTrackId = deezerData['id'].toString();
|
||||
_log.d('Found Deezer track ID via SongLink (flat): $deezerTrackId');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to convert Spotify to Deezer via SongLink: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
|
||||
try {
|
||||
final extendedMetadata =
|
||||
@@ -3305,6 +3381,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
progress: 0.95,
|
||||
);
|
||||
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
normalizedAlbumArtist,
|
||||
);
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
final isContentUriPath = isContentUri(filePath);
|
||||
if (isContentUriPath && effectiveSafMode) {
|
||||
// SAF mode: copy to temp, embed, write back
|
||||
@@ -3314,16 +3399,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (isMp3File) {
|
||||
await _embedMetadataToMp3(
|
||||
tempPath,
|
||||
trackToDownload,
|
||||
genre: genre,
|
||||
label: label,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
} else {
|
||||
await _embedMetadataToOpus(
|
||||
tempPath,
|
||||
trackToDownload,
|
||||
genre: genre,
|
||||
label: label,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
}
|
||||
// Write back to SAF
|
||||
@@ -3360,16 +3447,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (isMp3File) {
|
||||
await _embedMetadataToMp3(
|
||||
filePath,
|
||||
trackToDownload,
|
||||
genre: genre,
|
||||
label: label,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
} else {
|
||||
await _embedMetadataToOpus(
|
||||
filePath,
|
||||
trackToDownload,
|
||||
genre: genre,
|
||||
label: label,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
}
|
||||
_log.d('YouTube metadata embedding completed');
|
||||
|
||||
@@ -1549,7 +1549,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
duration: item.durationMs ~/ 1000,
|
||||
trackNumber: 1,
|
||||
discNumber: 1,
|
||||
isrc: item.id,
|
||||
isrc: null,
|
||||
releaseDate: null,
|
||||
coverUrl: item.coverUrl,
|
||||
source: item.providerId ?? 'spotify-web',
|
||||
|
||||
@@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||
static const _builtInServices = ['tidal', 'qobuz'];
|
||||
int _androidSdkVersion = 0;
|
||||
bool _hasAllFilesAccess = false;
|
||||
|
||||
@@ -248,7 +248,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Select Tidal, Qobuz, or Amazon above to configure quality',
|
||||
'Select Tidal or Qobuz above to configure quality',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
@@ -1354,7 +1354,6 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
final isExtensionService = ![
|
||||
'tidal',
|
||||
'qobuz',
|
||||
'amazon',
|
||||
].contains(currentService);
|
||||
final isCurrentExtensionEnabled = isExtensionService
|
||||
? extensionProviders.any((e) => e.id == currentService)
|
||||
@@ -1381,15 +1380,6 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
isSelected: effectiveService == 'qobuz',
|
||||
onTap: () => onChanged('qobuz'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.shopping_bag,
|
||||
label: 'Amazon',
|
||||
isSelected: effectiveService == 'amazon',
|
||||
isDisabled: true,
|
||||
disabledReason: 'Coming soon',
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (extensionProviders.isNotEmpty) ...[
|
||||
@@ -1425,15 +1415,11 @@ class _ServiceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final bool isDisabled;
|
||||
final String? disabledReason;
|
||||
const _ServiceChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.isDisabled = false,
|
||||
this.disabledReason,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -1448,66 +1434,39 @@ class _ServiceChip extends StatelessWidget {
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
final disabledColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.02),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerLow;
|
||||
|
||||
return Expanded(
|
||||
child: Tooltip(
|
||||
message: isDisabled && disabledReason != null ? disabledReason! : '',
|
||||
child: Material(
|
||||
color: isDisabled
|
||||
? disabledColor
|
||||
: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: unselectedColor,
|
||||
child: Material(
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer
|
||||
: unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: isDisabled ? null : onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isDisabled
|
||||
? colorScheme.onSurface.withValues(alpha: 0.38)
|
||||
: isSelected
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected && !isDisabled
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: isDisabled
|
||||
? colorScheme.onSurface.withValues(alpha: 0.38)
|
||||
: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (isDisabled && disabledReason != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
disabledReason!,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.38),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
class TrackMetadataScreen extends ConsumerStatefulWidget {
|
||||
@@ -35,6 +36,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
|
||||
bool _isEmbedding = false; // Track embed operation in progress
|
||||
bool _isInstrumental = false; // Track if detected as instrumental
|
||||
Map<String, dynamic>? _editedMetadata; // Overrides after metadata edit
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
static final RegExp _lrcTimestampPattern =
|
||||
RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
|
||||
@@ -117,17 +119,35 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
LocalLibraryItem? get _localLibraryItem => widget.localItem;
|
||||
|
||||
String get _itemId => _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id;
|
||||
String get trackName => _isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName;
|
||||
String get artistName => _isLocalItem ? _localLibraryItem!.artistName : _downloadItem!.artistName;
|
||||
String get albumName => _isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName;
|
||||
String? get albumArtist => _normalizeOptionalString(_isLocalItem ? _localLibraryItem!.albumArtist : _downloadItem!.albumArtist);
|
||||
int? get trackNumber => _isLocalItem ? _localLibraryItem!.trackNumber : _downloadItem!.trackNumber;
|
||||
int? get discNumber => _isLocalItem ? _localLibraryItem!.discNumber : _downloadItem!.discNumber;
|
||||
String? get releaseDate => _isLocalItem ? _localLibraryItem!.releaseDate : _downloadItem!.releaseDate;
|
||||
String? get isrc => _isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc;
|
||||
String? get genre => _isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre;
|
||||
String? get label => _isLocalItem ? null : _downloadItem!.label;
|
||||
String? get copyright => _isLocalItem ? null : _downloadItem!.copyright;
|
||||
String get trackName => _editedMetadata?['title']?.toString() ?? (_isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName);
|
||||
String get artistName => _editedMetadata?['artist']?.toString() ?? (_isLocalItem ? _localLibraryItem!.artistName : _downloadItem!.artistName);
|
||||
String get albumName => _editedMetadata?['album']?.toString() ?? (_isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName);
|
||||
String? get albumArtist {
|
||||
final edited = _editedMetadata?['album_artist']?.toString();
|
||||
if (edited != null && edited.isNotEmpty) return edited;
|
||||
return _normalizeOptionalString(_isLocalItem ? _localLibraryItem!.albumArtist : _downloadItem!.albumArtist);
|
||||
}
|
||||
int? get trackNumber {
|
||||
final edited = _editedMetadata?['track_number'];
|
||||
if (edited != null) {
|
||||
final v = int.tryParse(edited.toString());
|
||||
if (v != null && v > 0) return v;
|
||||
}
|
||||
return _isLocalItem ? _localLibraryItem!.trackNumber : _downloadItem!.trackNumber;
|
||||
}
|
||||
int? get discNumber {
|
||||
final edited = _editedMetadata?['disc_number'];
|
||||
if (edited != null) {
|
||||
final v = int.tryParse(edited.toString());
|
||||
if (v != null && v > 0) return v;
|
||||
}
|
||||
return _isLocalItem ? _localLibraryItem!.discNumber : _downloadItem!.discNumber;
|
||||
}
|
||||
String? get releaseDate => _editedMetadata?['date']?.toString() ?? (_isLocalItem ? _localLibraryItem!.releaseDate : _downloadItem!.releaseDate);
|
||||
String? get isrc => _editedMetadata?['isrc']?.toString() ?? (_isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc);
|
||||
String? get genre => _editedMetadata?['genre']?.toString() ?? (_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre);
|
||||
String? get label => _editedMetadata?['label']?.toString() ?? (_isLocalItem ? null : _downloadItem!.label);
|
||||
String? get copyright => _editedMetadata?['copyright']?.toString() ?? (_isLocalItem ? null : _downloadItem!.copyright);
|
||||
int? get duration => _isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration;
|
||||
int? get bitDepth => _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
|
||||
int? get sampleRate => _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
|
||||
@@ -1083,6 +1103,380 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
String _buildSaveBaseName() {
|
||||
final artist = artistName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
||||
final track = trackName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
||||
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) {
|
||||
// SAF file: save to temp, then copy to SAF tree
|
||||
final tempDir = await Directory.systemTemp.createTemp('cover_');
|
||||
final tempOutput = '${tempDir.path}${Platform.pathSeparator}$baseName.jpg';
|
||||
|
||||
Map<String, dynamic> result;
|
||||
if (_coverUrl != null && _coverUrl!.isNotEmpty) {
|
||||
result = await PlatformBridge.downloadCoverToFile(
|
||||
_coverUrl!,
|
||||
tempOutput,
|
||||
maxQuality: true,
|
||||
);
|
||||
} else if (_fileExists) {
|
||||
result = await PlatformBridge.extractCoverToFile(
|
||||
cleanFilePath,
|
||||
tempOutput,
|
||||
);
|
||||
} 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(result['error'].toString()))),
|
||||
);
|
||||
}
|
||||
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
// Write temp file to SAF tree
|
||||
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 {
|
||||
// No SAF tree info, keep in temp
|
||||
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;
|
||||
}
|
||||
|
||||
// Regular file path
|
||||
final dir = _getFileDirectory();
|
||||
final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg';
|
||||
|
||||
Map<String, dynamic> result;
|
||||
if (_coverUrl != null && _coverUrl!.isNotEmpty) {
|
||||
result = await PlatformBridge.downloadCoverToFile(
|
||||
_coverUrl!,
|
||||
outputPath,
|
||||
maxQuality: true,
|
||||
);
|
||||
} else if (_fileExists) {
|
||||
result = await PlatformBridge.extractCoverToFile(
|
||||
cleanFilePath,
|
||||
outputPath,
|
||||
);
|
||||
} 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(result['error'].toString()))),
|
||||
);
|
||||
} 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(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveLyrics() async {
|
||||
try {
|
||||
final baseName = _buildSaveBaseName();
|
||||
final durationMs = (duration ?? 0) * 1000;
|
||||
|
||||
if (_isSafFile) {
|
||||
// SAF file: save to temp, then copy to SAF tree
|
||||
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,
|
||||
);
|
||||
|
||||
if (result['error'] != null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))),
|
||||
);
|
||||
}
|
||||
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
// Write temp file to SAF tree
|
||||
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: 'text/plain',
|
||||
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.trackLyricsSaved(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;
|
||||
}
|
||||
|
||||
// Regular file path
|
||||
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,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
if (result['error'] != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackLyricsSaved(baseName))),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reEnrichMetadata() async {
|
||||
if (!_fileExists) return;
|
||||
|
||||
try {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackReEnrichSearching)),
|
||||
);
|
||||
|
||||
final durationMs = (duration ?? 0) * 1000;
|
||||
final request = <String, dynamic>{
|
||||
'file_path': cleanFilePath,
|
||||
'cover_url': _coverUrl ?? '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': true,
|
||||
'spotify_id': _spotifyId ?? '',
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
'album_name': albumName,
|
||||
'album_artist': albumArtist ?? artistName,
|
||||
'track_number': trackNumber ?? 0,
|
||||
'disc_number': discNumber ?? 0,
|
||||
'release_date': releaseDate ?? '',
|
||||
'isrc': isrc ?? '',
|
||||
'genre': genre ?? '',
|
||||
'label': label ?? '',
|
||||
'copyright': copyright ?? '',
|
||||
'duration_ms': durationMs,
|
||||
'search_online': true,
|
||||
};
|
||||
|
||||
final result = await PlatformBridge.reEnrichFile(request);
|
||||
final method = result['method'] as String?;
|
||||
|
||||
// Update local UI state with enriched metadata from online search
|
||||
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,
|
||||
'disc_number': enriched['disc_number'] ?? discNumber,
|
||||
'isrc': enriched['isrc'] ?? isrc,
|
||||
'genre': enriched['genre'] ?? genre,
|
||||
'label': enriched['label'] ?? label,
|
||||
'copyright': enriched['copyright'] ?? copyright,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (method == 'native') {
|
||||
// FLAC - handled natively by Go (SAF write-back handled in Kotlin)
|
||||
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 coverPath = result['cover_path'] as String?;
|
||||
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: coverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: ffmpegTarget,
|
||||
coverPath: coverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
// For SAF files, copy processed temp file back
|
||||
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('Failed to write back to storage'))),
|
||||
);
|
||||
// Cleanup temp files
|
||||
if (coverPath != null && coverPath.isNotEmpty) {
|
||||
try { await File(coverPath).delete(); } catch (_) {}
|
||||
}
|
||||
if (tempPath.isNotEmpty) {
|
||||
try { await File(tempPath).delete(); } catch (_) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup temp files
|
||||
if (tempPath != null && tempPath.isNotEmpty) {
|
||||
try { await File(tempPath).delete(); } catch (_) {}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
if (ffmpegResult != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackReEnrichSuccess)),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackReEnrichFfmpegFailed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup temp cover from Go backend
|
||||
if (coverPath != null && coverPath.isNotEmpty) {
|
||||
try { await File(coverPath).delete(); } catch (_) {}
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
final error = result['error']?.toString() ?? 'Unknown error';
|
||||
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(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _cleanLrcForDisplay(String lrc) {
|
||||
final lines = lrc.split('\n');
|
||||
final cleanLines = <String>[];
|
||||
@@ -1148,8 +1542,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.7,
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
@@ -1170,6 +1569,46 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_copyToClipboard(context, cleanFilePath);
|
||||
},
|
||||
),
|
||||
if (_fileExists)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_outlined),
|
||||
title: Text(context.l10n.trackEditMetadata),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showEditMetadataSheet(context, ref, colorScheme);
|
||||
},
|
||||
),
|
||||
if (!_isLocalItem && (_coverUrl != null || _fileExists))
|
||||
ListTile(
|
||||
leading: const Icon(Icons.image_outlined),
|
||||
title: Text(context.l10n.trackSaveCoverArt),
|
||||
subtitle: Text(context.l10n.trackSaveCoverArtSubtitle),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_saveCoverArt();
|
||||
},
|
||||
),
|
||||
if (!_isLocalItem)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.lyrics_outlined),
|
||||
title: Text(context.l10n.trackSaveLyrics),
|
||||
subtitle: Text(context.l10n.trackSaveLyricsSubtitle),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_saveLyrics();
|
||||
},
|
||||
),
|
||||
if (_fileExists)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.travel_explore),
|
||||
title: Text(context.l10n.trackReEnrich),
|
||||
subtitle: Text(context.l10n.trackReEnrichOnlineSubtitle),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_reEnrichMetadata();
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.share),
|
||||
title: Text(context.l10n.trackMetadataShare),
|
||||
@@ -1189,10 +1628,75 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditMetadataSheet(BuildContext context, WidgetRef ref, ColorScheme colorScheme) async {
|
||||
// Read current metadata from file, fall back to item data on failure
|
||||
Map<String, dynamic>? fileMetadata;
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
|
||||
if (result['error'] == null) {
|
||||
fileMetadata = result;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('readFileMetadata failed, using item data: $e');
|
||||
}
|
||||
|
||||
// Build initial values map — prefer file metadata, fall back to item data
|
||||
String val(String key, String? fallback) {
|
||||
final v = fileMetadata?[key]?.toString();
|
||||
return (v != null && v.isNotEmpty) ? v : (fallback ?? '');
|
||||
}
|
||||
|
||||
final initialValues = <String, String>{
|
||||
'title': val('title', trackName),
|
||||
'artist': val('artist', artistName),
|
||||
'album': val('album', albumName),
|
||||
'album_artist': val('album_artist', albumArtist),
|
||||
'date': val('date', releaseDate),
|
||||
'track_number': (fileMetadata?['track_number'] ?? trackNumber ?? '').toString(),
|
||||
'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '').toString(),
|
||||
'genre': val('genre', genre),
|
||||
'isrc': val('isrc', isrc),
|
||||
'label': val('label', label),
|
||||
'copyright': val('copyright', copyright),
|
||||
'composer': fileMetadata?['composer']?.toString() ?? '',
|
||||
'comment': fileMetadata?['comment']?.toString() ?? '',
|
||||
};
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
final saved = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (sheetContext) => _EditMetadataSheet(
|
||||
colorScheme: colorScheme,
|
||||
initialValues: initialValues,
|
||||
filePath: cleanFilePath,
|
||||
),
|
||||
);
|
||||
|
||||
if (saved == true && mounted) {
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
const SnackBar(content: Text('Metadata saved successfully')),
|
||||
);
|
||||
// Re-read metadata from file to refresh the display
|
||||
try {
|
||||
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
|
||||
setState(() => _editedMetadata = refreshed);
|
||||
} catch (_) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -1324,17 +1828,380 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Color _getServiceColor(String service, ColorScheme colorScheme) {
|
||||
switch (service.toLowerCase()) {
|
||||
case 'tidal':
|
||||
return const Color(0xFF0077B5); // Tidal blue (darker, more readable)
|
||||
return const Color(0xFF0077B5);
|
||||
case 'qobuz':
|
||||
return const Color(0xFF0052CC); // Qobuz blue
|
||||
return const Color(0xFF0052CC);
|
||||
case 'amazon':
|
||||
return const Color(0xFFFF9900); // Amazon orange
|
||||
return const Color(0xFFFF9900);
|
||||
default:
|
||||
return colorScheme.primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _EditMetadataSheet extends StatefulWidget {
|
||||
final ColorScheme colorScheme;
|
||||
final Map<String, String> initialValues;
|
||||
final String filePath;
|
||||
|
||||
const _EditMetadataSheet({
|
||||
required this.colorScheme,
|
||||
required this.initialValues,
|
||||
required this.filePath,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_EditMetadataSheet> createState() => _EditMetadataSheetState();
|
||||
}
|
||||
|
||||
class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
bool _saving = false;
|
||||
bool _showAdvanced = false;
|
||||
|
||||
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 _discNumCtrl;
|
||||
late final TextEditingController _genreCtrl;
|
||||
late final TextEditingController _isrcCtrl;
|
||||
late final TextEditingController _labelCtrl;
|
||||
late final TextEditingController _copyrightCtrl;
|
||||
late final TextEditingController _composerCtrl;
|
||||
late final TextEditingController _commentCtrl;
|
||||
|
||||
@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'] ?? '');
|
||||
_discNumCtrl = TextEditingController(text: v['disc_number'] ?? '');
|
||||
_genreCtrl = TextEditingController(text: v['genre'] ?? '');
|
||||
_isrcCtrl = TextEditingController(text: v['isrc'] ?? '');
|
||||
_labelCtrl = TextEditingController(text: v['label'] ?? '');
|
||||
_copyrightCtrl = TextEditingController(text: v['copyright'] ?? '');
|
||||
_composerCtrl = TextEditingController(text: v['composer'] ?? '');
|
||||
_commentCtrl = TextEditingController(text: v['comment'] ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleCtrl.dispose();
|
||||
_artistCtrl.dispose();
|
||||
_albumCtrl.dispose();
|
||||
_albumArtistCtrl.dispose();
|
||||
_dateCtrl.dispose();
|
||||
_trackNumCtrl.dispose();
|
||||
_discNumCtrl.dispose();
|
||||
_genreCtrl.dispose();
|
||||
_isrcCtrl.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,
|
||||
'disc_number': _discNumCtrl.text,
|
||||
'genre': _genreCtrl.text,
|
||||
'isrc': _isrcCtrl.text,
|
||||
'label': _labelCtrl.text,
|
||||
'copyright': _copyrightCtrl.text,
|
||||
'composer': _composerCtrl.text,
|
||||
'comment': _commentCtrl.text,
|
||||
};
|
||||
|
||||
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') {
|
||||
// MP3/Opus: use FFmpeg to write metadata
|
||||
// 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 vorbisMap = <String, String>{};
|
||||
if (metadata['title']?.isNotEmpty == true) vorbisMap['TITLE'] = metadata['title']!;
|
||||
if (metadata['artist']?.isNotEmpty == true) vorbisMap['ARTIST'] = metadata['artist']!;
|
||||
if (metadata['album']?.isNotEmpty == true) vorbisMap['ALBUM'] = metadata['album']!;
|
||||
if (metadata['album_artist']?.isNotEmpty == true) vorbisMap['ALBUMARTIST'] = metadata['album_artist']!;
|
||||
if (metadata['date']?.isNotEmpty == true) vorbisMap['DATE'] = metadata['date']!;
|
||||
if (metadata['track_number']?.isNotEmpty == true && metadata['track_number'] != '0') {
|
||||
vorbisMap['TRACKNUMBER'] = metadata['track_number']!;
|
||||
}
|
||||
if (metadata['disc_number']?.isNotEmpty == true && metadata['disc_number'] != '0') {
|
||||
vorbisMap['DISCNUMBER'] = metadata['disc_number']!;
|
||||
}
|
||||
if (metadata['genre']?.isNotEmpty == true) vorbisMap['GENRE'] = metadata['genre']!;
|
||||
if (metadata['isrc']?.isNotEmpty == true) vorbisMap['ISRC'] = metadata['isrc']!;
|
||||
if (metadata['label']?.isNotEmpty == true) vorbisMap['ORGANIZATION'] = metadata['label']!;
|
||||
if (metadata['copyright']?.isNotEmpty == true) vorbisMap['COPYRIGHT'] = metadata['copyright']!;
|
||||
if (metadata['composer']?.isNotEmpty == true) vorbisMap['COMPOSER'] = metadata['composer']!;
|
||||
if (metadata['comment']?.isNotEmpty == true) vorbisMap['COMMENT'] = metadata['comment']!;
|
||||
|
||||
// Extract existing cover art before re-embedding metadata
|
||||
String? existingCoverPath;
|
||||
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;
|
||||
}
|
||||
} catch (_) {
|
||||
// No cover to preserve, continue without
|
||||
}
|
||||
|
||||
String? ffmpegResult;
|
||||
if (isMp3) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
||||
mp3Path: ffmpegTarget,
|
||||
coverPath: existingCoverPath,
|
||||
metadata: vorbisMap,
|
||||
);
|
||||
} else if (isOpus) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: ffmpegTarget,
|
||||
coverPath: existingCoverPath,
|
||||
metadata: vorbisMap,
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup temp cover
|
||||
if (existingCoverPath != null) {
|
||||
try { await File(existingCoverPath).delete(); } catch (_) {}
|
||||
}
|
||||
|
||||
if (ffmpegResult == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to save metadata via FFmpeg')),
|
||||
);
|
||||
}
|
||||
setState(() => _saving = false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For SAF files, copy the processed temp file back
|
||||
if (tempPath != null && safUri != null) {
|
||||
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
|
||||
if (!ok && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to write metadata back to storage')),
|
||||
);
|
||||
setState(() => _saving = false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to save metadata: $e')),
|
||||
);
|
||||
}
|
||||
} 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: [
|
||||
// Handle bar
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Title row
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Edit Metadata',
|
||||
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: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Fields
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
children: [
|
||||
_field('Title', _titleCtrl),
|
||||
_field('Artist', _artistCtrl),
|
||||
_field('Album', _albumCtrl),
|
||||
_field('Album Artist', _albumArtistCtrl),
|
||||
_field('Date', _dateCtrl, hint: 'YYYY-MM-DD or YYYY'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _field('Track #', _trackNumCtrl, keyboard: TextInputType.number)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _field('Disc #', _discNumCtrl, keyboard: TextInputType.number)),
|
||||
],
|
||||
),
|
||||
_field('Genre', _genreCtrl),
|
||||
_field('ISRC', _isrcCtrl),
|
||||
// Advanced fields toggle
|
||||
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(
|
||||
'Advanced',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_showAdvanced) ...[
|
||||
_field('Label', _labelCtrl),
|
||||
_field('Copyright', _copyrightCtrl),
|
||||
_field('Composer', _composerCtrl),
|
||||
_field('Comment', _commentCtrl, maxLines: 3),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -365,6 +365,7 @@ class FFmpegService {
|
||||
}
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
cmdBuffer.write('-map_metadata -1 ');
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-map 1:0 ');
|
||||
@@ -441,6 +442,8 @@ class FFmpegService {
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$opusPath" ');
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
cmdBuffer.write('-map_metadata -1 ');
|
||||
cmdBuffer.write('-map_metadata:s:a -1 ');
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
if (metadata != null) {
|
||||
@@ -654,6 +657,12 @@ class FFmpegService {
|
||||
case 'UNSYNCEDLYRICS':
|
||||
id3Map['lyrics'] = value;
|
||||
break;
|
||||
case 'COMPOSER':
|
||||
id3Map['composer'] = value;
|
||||
break;
|
||||
case 'COMMENT':
|
||||
id3Map['comment'] = value;
|
||||
break;
|
||||
default:
|
||||
id3Map[key.toLowerCase()] = value;
|
||||
}
|
||||
|
||||
+199
-129
@@ -374,6 +374,55 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('cleanupConnections');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> downloadCoverToFile(
|
||||
String coverUrl,
|
||||
String outputPath, {
|
||||
bool maxQuality = true,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('downloadCoverToFile', {
|
||||
'cover_url': coverUrl,
|
||||
'output_path': outputPath,
|
||||
'max_quality': maxQuality,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> extractCoverToFile(
|
||||
String audioPath,
|
||||
String outputPath,
|
||||
) async {
|
||||
final result = await _channel.invokeMethod('extractCoverToFile', {
|
||||
'audio_path': audioPath,
|
||||
'output_path': outputPath,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> fetchAndSaveLyrics({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
required String spotifyId,
|
||||
required int durationMs,
|
||||
required String outputPath,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('fetchAndSaveLyrics', {
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
'spotify_id': spotifyId,
|
||||
'duration_ms': durationMs,
|
||||
'output_path': outputPath,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> reEnrichFile(Map<String, dynamic> request) async {
|
||||
final requestJSON = jsonEncode(request);
|
||||
final result = await _channel.invokeMethod('reEnrichFile', {
|
||||
'request_json': requestJSON,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
|
||||
final result = await _channel.invokeMethod('readFileMetadata', {
|
||||
'file_path': filePath,
|
||||
@@ -381,6 +430,27 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> editFileMetadata(
|
||||
String filePath,
|
||||
Map<String, String> metadata,
|
||||
) async {
|
||||
final metadataJSON = jsonEncode(metadata);
|
||||
final result = await _channel.invokeMethod('editFileMetadata', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': metadataJSON,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<bool> writeTempToSaf(String tempPath, String safUri) async {
|
||||
final result = await _channel.invokeMethod('writeTempToSaf', {
|
||||
'temp_path': tempPath,
|
||||
'saf_uri': safUri,
|
||||
});
|
||||
final map = jsonDecode(result as String) as Map<String, dynamic>;
|
||||
return map['success'] == true;
|
||||
}
|
||||
|
||||
static Future<void> startDownloadService({
|
||||
String trackName = '',
|
||||
String artistName = '',
|
||||
@@ -953,68 +1023,68 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||
});
|
||||
}
|
||||
|
||||
/// Scan a folder for audio files and read their metadata
|
||||
/// Returns a list of track metadata
|
||||
static Future<List<Map<String, dynamic>>> scanLibraryFolder(String folderPath) async {
|
||||
_log.i('scanLibraryFolder: $folderPath');
|
||||
final result = await _channel.invokeMethod('scanLibraryFolder', {
|
||||
'folder_path': folderPath,
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Perform an incremental scan of the library folder
|
||||
/// Only scans files that are new or have changed since last scan
|
||||
/// [existingFiles] is a map of filePath -> modTime (unix millis)
|
||||
/// Returns IncrementalScanResult with scanned items, deleted paths, and skip count
|
||||
static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
|
||||
String folderPath,
|
||||
Map<String, int> existingFiles,
|
||||
) async {
|
||||
_log.i('scanLibraryFolderIncremental: $folderPath (${existingFiles.length} existing files)');
|
||||
final result = await _channel.invokeMethod('scanLibraryFolderIncremental', {
|
||||
'folder_path': folderPath,
|
||||
'existing_files': jsonEncode(existingFiles),
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
/// Scan a folder for audio files and read their metadata
|
||||
/// Returns a list of track metadata
|
||||
static Future<List<Map<String, dynamic>>> scanLibraryFolder(String folderPath) async {
|
||||
_log.i('scanLibraryFolder: $folderPath');
|
||||
final result = await _channel.invokeMethod('scanLibraryFolder', {
|
||||
'folder_path': folderPath,
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> scanSafTree(String treeUri) async {
|
||||
_log.i('scanSafTree: $treeUri');
|
||||
final result = await _channel.invokeMethod('scanSafTree', {
|
||||
'tree_uri': treeUri,
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Incremental SAF tree scan - only scans new or modified files
|
||||
/// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files)
|
||||
static Future<Map<String, dynamic>> scanSafTreeIncremental(
|
||||
String treeUri,
|
||||
Map<String, int> existingFiles,
|
||||
) async {
|
||||
_log.i('scanSafTreeIncremental: $treeUri (${existingFiles.length} existing files)');
|
||||
final result = await _channel.invokeMethod('scanSafTreeIncremental', {
|
||||
'tree_uri': treeUri,
|
||||
'existing_files': jsonEncode(existingFiles),
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get last-modified timestamps for a list of SAF file URIs.
|
||||
/// Returns map uri -> modTime (unix millis), only for files that still exist.
|
||||
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async {
|
||||
final result = await _channel.invokeMethod('getSafFileModTimes', {
|
||||
'uris': jsonEncode(uris),
|
||||
});
|
||||
final map = jsonDecode(result as String) as Map<String, dynamic>;
|
||||
return map.map((key, value) => MapEntry(key, (value as num).toInt()));
|
||||
}
|
||||
|
||||
/// Get current library scan progress
|
||||
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
|
||||
/// Perform an incremental scan of the library folder
|
||||
/// Only scans files that are new or have changed since last scan
|
||||
/// [existingFiles] is a map of filePath -> modTime (unix millis)
|
||||
/// Returns IncrementalScanResult with scanned items, deleted paths, and skip count
|
||||
static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
|
||||
String folderPath,
|
||||
Map<String, int> existingFiles,
|
||||
) async {
|
||||
_log.i('scanLibraryFolderIncremental: $folderPath (${existingFiles.length} existing files)');
|
||||
final result = await _channel.invokeMethod('scanLibraryFolderIncremental', {
|
||||
'folder_path': folderPath,
|
||||
'existing_files': jsonEncode(existingFiles),
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> scanSafTree(String treeUri) async {
|
||||
_log.i('scanSafTree: $treeUri');
|
||||
final result = await _channel.invokeMethod('scanSafTree', {
|
||||
'tree_uri': treeUri,
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Incremental SAF tree scan - only scans new or modified files
|
||||
/// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files)
|
||||
static Future<Map<String, dynamic>> scanSafTreeIncremental(
|
||||
String treeUri,
|
||||
Map<String, int> existingFiles,
|
||||
) async {
|
||||
_log.i('scanSafTreeIncremental: $treeUri (${existingFiles.length} existing files)');
|
||||
final result = await _channel.invokeMethod('scanSafTreeIncremental', {
|
||||
'tree_uri': treeUri,
|
||||
'existing_files': jsonEncode(existingFiles),
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get last-modified timestamps for a list of SAF file URIs.
|
||||
/// Returns map uri -> modTime (unix millis), only for files that still exist.
|
||||
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async {
|
||||
final result = await _channel.invokeMethod('getSafFileModTimes', {
|
||||
'uris': jsonEncode(uris),
|
||||
});
|
||||
final map = jsonDecode(result as String) as Map<String, dynamic>;
|
||||
return map.map((key, value) => MapEntry(key, (value as num).toInt()));
|
||||
}
|
||||
|
||||
/// Get current library scan progress
|
||||
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
|
||||
final result = await _channel.invokeMethod('getLibraryScanProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
@@ -1113,71 +1183,71 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
|
||||
return result as String;
|
||||
}
|
||||
|
||||
static Future<void> clearStoreCache() async {
|
||||
_log.d('clearStoreCache');
|
||||
await _channel.invokeMethod('clearStoreCache');
|
||||
}
|
||||
|
||||
// ==================== YOUTUBE / COBALT ====================
|
||||
|
||||
/// Download a track from YouTube using the Cobalt API.
|
||||
/// YouTube is a lossy-only provider (Opus 256kbps or MP3 320kbps).
|
||||
/// It does NOT participate in the lossless fallback chain.
|
||||
static Future<Map<String, dynamic>> downloadFromYouTube({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
required String albumName,
|
||||
String? albumArtist,
|
||||
String? coverUrl,
|
||||
required String outputDir,
|
||||
required String filenameFormat,
|
||||
String quality = 'opus_256',
|
||||
int trackNumber = 1,
|
||||
int discNumber = 1,
|
||||
String? releaseDate,
|
||||
String? itemId,
|
||||
int durationMs = 0,
|
||||
String? isrc,
|
||||
String? spotifyId,
|
||||
String? deezerId,
|
||||
String storageMode = 'app',
|
||||
String safTreeUri = '',
|
||||
String safRelativeDir = '',
|
||||
String safFileName = '',
|
||||
String safOutputExt = '',
|
||||
}) async {
|
||||
_log.i('downloadFromYouTube: "$trackName" by $artistName (quality: $quality)');
|
||||
final request = jsonEncode({
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
'album_name': albumName,
|
||||
'album_artist': albumArtist ?? artistName,
|
||||
'cover_url': coverUrl,
|
||||
'output_dir': outputDir,
|
||||
'filename_format': filenameFormat,
|
||||
'quality': quality,
|
||||
'track_number': trackNumber,
|
||||
'disc_number': discNumber,
|
||||
'release_date': releaseDate ?? '',
|
||||
'item_id': itemId ?? '',
|
||||
'duration_ms': durationMs,
|
||||
'isrc': isrc ?? '',
|
||||
'spotify_id': spotifyId ?? '',
|
||||
'deezer_id': deezerId ?? '',
|
||||
'storage_mode': storageMode,
|
||||
'saf_tree_uri': safTreeUri,
|
||||
'saf_relative_dir': safRelativeDir,
|
||||
'saf_file_name': safFileName,
|
||||
'saf_output_ext': safOutputExt,
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadFromYouTube', request);
|
||||
final response = jsonDecode(result as String) as Map<String, dynamic>;
|
||||
if (response['success'] == true) {
|
||||
_log.i('YouTube download success: ${response['file_path']}');
|
||||
} else {
|
||||
_log.w('YouTube download failed: ${response['error']}');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
static Future<void> clearStoreCache() async {
|
||||
_log.d('clearStoreCache');
|
||||
await _channel.invokeMethod('clearStoreCache');
|
||||
}
|
||||
|
||||
// ==================== YOUTUBE / COBALT ====================
|
||||
|
||||
/// Download a track from YouTube using the Cobalt API.
|
||||
/// YouTube is a lossy-only provider (Opus 256kbps or MP3 320kbps).
|
||||
/// It does NOT participate in the lossless fallback chain.
|
||||
static Future<Map<String, dynamic>> downloadFromYouTube({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
required String albumName,
|
||||
String? albumArtist,
|
||||
String? coverUrl,
|
||||
required String outputDir,
|
||||
required String filenameFormat,
|
||||
String quality = 'opus_256',
|
||||
int trackNumber = 1,
|
||||
int discNumber = 1,
|
||||
String? releaseDate,
|
||||
String? itemId,
|
||||
int durationMs = 0,
|
||||
String? isrc,
|
||||
String? spotifyId,
|
||||
String? deezerId,
|
||||
String storageMode = 'app',
|
||||
String safTreeUri = '',
|
||||
String safRelativeDir = '',
|
||||
String safFileName = '',
|
||||
String safOutputExt = '',
|
||||
}) async {
|
||||
_log.i('downloadFromYouTube: "$trackName" by $artistName (quality: $quality)');
|
||||
final request = jsonEncode({
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
'album_name': albumName,
|
||||
'album_artist': albumArtist ?? artistName,
|
||||
'cover_url': coverUrl,
|
||||
'output_dir': outputDir,
|
||||
'filename_format': filenameFormat,
|
||||
'quality': quality,
|
||||
'track_number': trackNumber,
|
||||
'disc_number': discNumber,
|
||||
'release_date': releaseDate ?? '',
|
||||
'item_id': itemId ?? '',
|
||||
'duration_ms': durationMs,
|
||||
'isrc': isrc ?? '',
|
||||
'spotify_id': spotifyId ?? '',
|
||||
'deezer_id': deezerId ?? '',
|
||||
'storage_mode': storageMode,
|
||||
'saf_tree_uri': safTreeUri,
|
||||
'saf_relative_dir': safRelativeDir,
|
||||
'saf_file_name': safFileName,
|
||||
'saf_output_ext': safOutputExt,
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadFromYouTube', request);
|
||||
final response = jsonDecode(result as String) as Map<String, dynamic>;
|
||||
if (response['success'] == true) {
|
||||
_log.i('YouTube download success: ${response['file_path']}');
|
||||
} else {
|
||||
_log.w('YouTube download failed: ${response['error']}');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user