diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 365ad757..fc937882 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1725,7 +1725,7 @@ class MainActivity: FlutterFragmentActivity() { val trackName = call.argument("track_name") ?: "" val artistName = call.argument("artist_name") ?: "" val spotifyId = call.argument("spotify_id") ?: "" - val durationMs = call.argument("duration_ms") ?: 0L + val durationMs = call.argument("duration_ms")?.toLong() ?: 0L val outputPath = call.argument("output_path") ?: "" val response = withContext(Dispatchers.IO) { try { diff --git a/go_backend/exports.go b/go_backend/exports.go index b09ce74c..0e2c1b5a 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strings" "time" @@ -782,6 +783,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { lower := strings.ToLower(filePath) isFlac := strings.HasSuffix(lower, ".flac") + coverPath := strings.TrimSpace(fields["cover_path"]) if isFlac { trackNum := 0 @@ -809,7 +811,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { Comment: fields["comment"], } - if err := EmbedMetadata(filePath, meta, ""); err != nil { + if err := EmbedMetadata(filePath, meta, coverPath); err != nil { return "", fmt.Errorf("failed to write FLAC metadata: %w", err) } @@ -1692,19 +1694,47 @@ func ReEnrichFile(requestJSON string) (string, error) { GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n", req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label) + lower := strings.ToLower(req.FilePath) + isFlac := strings.HasSuffix(lower, ".flac") + // Download cover art to temp file var coverTempPath string + var coverDataBytes []byte if req.CoverURL != "" { coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality) if err != nil { GoLog("[ReEnrich] Failed to download cover: %v\n", err) } else { - tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg") - if err == nil { - coverTempPath = tmpFile.Name() - tmpFile.Write(coverData) - tmpFile.Close() - GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024) + coverDataBytes = coverData + GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024) + // MP3/Opus requires a real image file path for Dart FFmpeg. + // FLAC uses in-memory embed and does not require temp files. + if !isFlac { + tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg") + if err != nil { + fallbackDir := filepath.Dir(req.FilePath) + if fallbackDir == "" || fallbackDir == "." { + GoLog("[ReEnrich] Failed to create cover temp file: %v\n", err) + } else { + tmpFile, err = os.CreateTemp(fallbackDir, "reenrich_cover_*.jpg") + if err != nil { + GoLog("[ReEnrich] Failed to create cover temp file (fallback dir %s): %v\n", fallbackDir, err) + } + } + } + if err == nil && tmpFile != nil { + coverTempPath = tmpFile.Name() + if _, writeErr := tmpFile.Write(coverData); writeErr != nil { + GoLog("[ReEnrich] Failed writing cover temp file: %v\n", writeErr) + tmpFile.Close() + os.Remove(coverTempPath) + coverTempPath = "" + } else if closeErr := tmpFile.Close(); closeErr != nil { + GoLog("[ReEnrich] Failed closing cover temp file: %v\n", closeErr) + os.Remove(coverTempPath) + coverTempPath = "" + } + } } } } @@ -1734,9 +1764,6 @@ func ReEnrichFile(requestJSON string) (string, error) { } } - lower := strings.ToLower(req.FilePath) - isFlac := strings.HasSuffix(lower, ".flac") - // Build enriched metadata response for Dart (includes online search results) enrichedMeta := map[string]interface{}{ "track_name": req.TrackName, @@ -1772,8 +1799,24 @@ func ReEnrichFile(requestJSON string) (string, error) { Lyrics: lyricsLRC, } - if err := EmbedMetadata(req.FilePath, metadata, coverTempPath); err != nil { - return "", fmt.Errorf("failed to embed metadata: %w", err) + if len(coverDataBytes) > 0 { + if err := EmbedMetadataWithCoverData(req.FilePath, metadata, coverDataBytes); err != nil { + return "", fmt.Errorf("failed to embed metadata with cover: %w", err) + } + } else { + if err := EmbedMetadata(req.FilePath, metadata, ""); err != nil { + return "", fmt.Errorf("failed to embed metadata: %w", err) + } + } + if len(coverDataBytes) > 0 { + embeddedCover, err := ExtractCoverArt(req.FilePath) + if err != nil || len(embeddedCover) == 0 { + if err != nil { + return "", fmt.Errorf("metadata embedded but cover verification failed: %w", err) + } + return "", fmt.Errorf("metadata embedded but cover verification failed: empty embedded cover") + } + GoLog("[ReEnrich] Cover verified after embed (%d bytes)\n", len(embeddedCover)) } GoLog("[ReEnrich] FLAC metadata embedded successfully\n") diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 370218e4..e0034dbd 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -4,8 +4,13 @@ import ( "bytes" "encoding/binary" "fmt" + stdimage "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" "io" "os" + "path/filepath" "strconv" "strings" @@ -14,6 +19,82 @@ import ( "github.com/go-flac/go-flac/v2" ) +func detectCoverMIME(coverPath string, coverData []byte) string { + // Prefer magic-byte detection over file extension. + // Some providers return non-JPEG data behind .jpg URLs. + if len(coverData) >= 8 && + coverData[0] == 0x89 && + coverData[1] == 0x50 && + coverData[2] == 0x4E && + coverData[3] == 0x47 && + coverData[4] == 0x0D && + coverData[5] == 0x0A && + coverData[6] == 0x1A && + coverData[7] == 0x0A { + return "image/png" + } + if len(coverData) >= 3 && + coverData[0] == 0xFF && + coverData[1] == 0xD8 && + coverData[2] == 0xFF { + return "image/jpeg" + } + if len(coverData) >= 6 { + header := string(coverData[:6]) + if header == "GIF87a" || header == "GIF89a" { + return "image/gif" + } + } + if len(coverData) >= 12 && + string(coverData[:4]) == "RIFF" && + string(coverData[8:12]) == "WEBP" { + return "image/webp" + } + + switch strings.ToLower(filepath.Ext(strings.TrimSpace(coverPath))) { + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".webp": + return "image/webp" + case ".gif": + return "image/gif" + } + + return "image/jpeg" +} + +func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) { + if len(coverData) == 0 { + return flac.MetaDataBlock{}, fmt.Errorf("empty cover data") + } + + mime := detectCoverMIME(coverPath, coverData) + picture := &flacpicture.MetadataBlockPicture{ + PictureType: flacpicture.PictureTypeFrontCover, + MIME: mime, + Description: "Front Cover", + ImageData: coverData, + } + + // Width/height/depth are optional in practice; keep zero when decode fails. + if cfg, format, err := stdimage.DecodeConfig(bytes.NewReader(coverData)); err == nil { + picture.Width = uint32(cfg.Width) + picture.Height = uint32(cfg.Height) + switch format { + case "png": + picture.ColorDepth = 32 + case "jpeg": + picture.ColorDepth = 24 + default: + picture.ColorDepth = 0 + } + } + + return picture.Marshal(), nil +} + type Metadata struct { Title string Artist string @@ -127,19 +208,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { } } - picture, err := flacpicture.NewFromImageData( - flacpicture.PictureTypeFrontCover, - "Front Cover", - coverData, - "image/jpeg", - ) + picBlock, err := buildPictureBlock(coverPath, coverData) if err != nil { - fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err) - } else { - picBlock := picture.Marshal() - f.Meta = append(f.Meta, &picBlock) - fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData)) + return fmt.Errorf("failed to create picture block: %w", err) } + f.Meta = append(f.Meta, &picBlock) + fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData)) } } else { fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath) @@ -238,19 +312,12 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData [] } } - picture, err := flacpicture.NewFromImageData( - flacpicture.PictureTypeFrontCover, - "Front Cover", - coverData, - "image/jpeg", - ) + picBlock, err := buildPictureBlock("", coverData) if err != nil { - fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err) - } else { - picBlock := picture.Marshal() - f.Meta = append(f.Meta, &picBlock) - fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData)) + return fmt.Errorf("failed to create picture block: %w", err) } + f.Meta = append(f.Meta, &picBlock) + fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData)) } return f.Save(filePath) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f05056d5..34e49062 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5068,6 +5068,12 @@ abstract class AppLocalizations { /// **'Fetch and save lyrics as .lrc file'** String get trackSaveLyricsSubtitle; + /// Snackbar while saving lyrics to file + /// + /// In en, this message translates to: + /// **'Saving lyrics...'** + String get trackSaveLyricsProgress; + /// Menu action - re-embed metadata into audio file /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 61c28595..b405e5b7 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2865,6 +2865,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + @override String get trackReEnrich => 'Re-enrich Metadata'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index bafc4f3f..f8c318ff 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2851,6 +2851,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + @override String get trackReEnrich => 'Re-enrich Metadata'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index e2c130b7..cfd26679 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2851,6 +2851,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + @override String get trackReEnrich => 'Re-enrich Metadata'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 6c658887..d07bc20f 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2851,6 +2851,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + @override String get trackReEnrich => 'Re-enrich Metadata'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 6a3ce249..4391e763 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2851,6 +2851,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + @override String get trackReEnrich => 'Re-enrich Metadata'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 52cf8925..ae16675d 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2869,6 +2869,9 @@ class AppLocalizationsId extends AppLocalizations { String get trackSaveLyricsSubtitle => 'Ambil dan simpan lirik sebagai file .lrc'; + @override + String get trackSaveLyricsProgress => 'Menyimpan lirik...'; + @override String get trackReEnrich => 'Perkaya Ulang Metadata'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 3e9a1b3d..23d2d69c 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2837,6 +2837,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + @override String get trackReEnrich => 'Re-enrich Metadata'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index a74e36d4..74b97adb 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2851,6 +2851,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + @override String get trackReEnrich => 'Re-enrich Metadata'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 52c05aa9..2d2e32cf 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2851,6 +2851,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + @override String get trackReEnrich => 'Re-enrich Metadata'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index cea6c5fe..5d2c37a9 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2851,6 +2851,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + @override String get trackReEnrich => 'Re-enrich Metadata'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 79438058..a491c0e3 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2897,6 +2897,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + @override String get trackReEnrich => 'Re-enrich Metadata'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 004e1119..b5fad48e 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2866,6 +2866,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + @override String get trackReEnrich => 'Re-enrich Metadata'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index eb41c0d9..65a694d2 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2851,6 +2851,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + @override + String get trackSaveLyricsProgress => 'Saving lyrics...'; + @override String get trackReEnrich => 'Re-enrich Metadata'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 03e26018..ecf0e423 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2146,6 +2146,8 @@ "@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"}, "trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file", "@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"}, + "trackSaveLyricsProgress": "Saving lyrics...", + "@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"}, "trackReEnrich": "Re-enrich Metadata", "@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"}, "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 43b98f13..853f24fb 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -3164,11 +3164,13 @@ "@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", + "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"}, + "trackSaveLyricsProgress": "Menyimpan lirik...", + "@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"}, + "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"}, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 857f4508..18fa4127 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/library_database.dart'; @@ -47,6 +48,7 @@ class _TrackMetadataScreenState extends ConsumerState { bool _isInstrumental = false; // Track if detected as instrumental bool _isConverting = false; // Track convert operation in progress Map? _editedMetadata; // Overrides after metadata edit + String? _embeddedCoverPreviewPath; final ScrollController _scrollController = ScrollController(); static final RegExp _lrcTimestampPattern = RegExp( r'^\[\d{2}:\d{2}\.\d{2,3}\]', @@ -84,6 +86,7 @@ class _TrackMetadataScreenState extends ConsumerState { @override void dispose() { + _cleanupTempFileAndParentSync(_embeddedCoverPreviewPath); _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); @@ -124,6 +127,82 @@ class _TrackMetadataScreenState extends ConsumerState { } } + bool _hasPath(String? path) => path != null && path.trim().isNotEmpty; + + Future _cleanupTempFileAndParent(String? path) async { + if (!_hasPath(path)) return; + final file = File(path!); + try { + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + try { + final dir = file.parent; + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } catch (_) {} + } + + void _cleanupTempFileAndParentSync(String? path) { + if (!_hasPath(path)) return; + final file = File(path!); + try { + if (file.existsSync()) { + file.deleteSync(); + } + } catch (_) {} + try { + final dir = file.parent; + if (dir.existsSync()) { + dir.deleteSync(recursive: true); + } + } catch (_) {} + } + + Future _refreshEmbeddedCoverPreview() async { + String? newPreviewPath; + try { + if (!_fileExists) { + await _cleanupTempFileAndParent(_embeddedCoverPreviewPath); + if (mounted) { + setState(() => _embeddedCoverPreviewPath = null); + } + return; + } + final tempDir = await Directory.systemTemp.createTemp( + 'track_cover_preview_', + ); + final outputPath = + '${tempDir.path}${Platform.pathSeparator}cover_preview.jpg'; + final result = await PlatformBridge.extractCoverToFile( + cleanFilePath, + outputPath, + ); + if (result['error'] == null && await File(outputPath).exists()) { + newPreviewPath = outputPath; + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + + final oldPreviewPath = _embeddedCoverPreviewPath; + if (!mounted) { + if (newPreviewPath != null) { + await _cleanupTempFileAndParent(newPreviewPath); + } + return; + } + + setState(() => _embeddedCoverPreviewPath = newPreviewPath); + if (oldPreviewPath != null && oldPreviewPath != newPreviewPath) { + await _cleanupTempFileAndParent(oldPreviewPath); + } + } + bool get _isLocalItem => widget.localItem != null; DownloadHistoryItem? get _downloadItem => widget.item; LocalLibraryItem? get _localLibraryItem => widget.localItem; @@ -341,7 +420,13 @@ class _TrackMetadataScreenState extends ConsumerState { fit: StackFit.expand, children: [ // Blurred cover art background - if (_coverUrl != null) + if (_hasPath(_embeddedCoverPreviewPath)) + Image.file( + File(_embeddedCoverPreviewPath!), + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container(color: colorScheme.surface), + ) + else if (_coverUrl != null) CachedNetworkImage( imageUrl: _coverUrl!, fit: BoxFit.cover, @@ -410,7 +495,20 @@ class _TrackMetadataScreenState extends ConsumerState { ), child: ClipRRect( borderRadius: BorderRadius.circular(20), - child: _coverUrl != null + child: _hasPath(_embeddedCoverPreviewPath) + ? Image.file( + File(_embeddedCoverPreviewPath!), + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : _coverUrl != null ? CachedNetworkImage( imageUrl: _coverUrl!, fit: BoxFit.cover, @@ -1492,6 +1590,13 @@ class _TrackMetadataScreenState extends ConsumerState { try { final baseName = _buildSaveBaseName(); final durationMs = (duration ?? 0) * 1000; + if (mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveLyricsProgress)), + ); + } if (_isSafFile) { // SAF file: save to temp, then copy to SAF tree @@ -1509,13 +1614,15 @@ class _TrackMetadataScreenState extends ConsumerState { if (result['error'] != null) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed(result['error'].toString()), + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed(result['error'].toString()), + ), ), - ), - ); + ); } try { await Directory(tempDir.path).delete(recursive: true); @@ -1539,19 +1646,25 @@ class _TrackMetadataScreenState extends ConsumerState { } 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'), + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(context.l10n.trackLyricsSaved(baseName)), ), - ), - ); + ); + } else { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed( + 'Failed to write to storage', + ), + ), + ), + ); } } } else { @@ -1559,13 +1672,15 @@ class _TrackMetadataScreenState extends ConsumerState { await Directory(tempDir.path).delete(recursive: true); } catch (_) {} if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed('No storage access'), + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed('No storage access'), + ), ), - ), - ); + ); } } return; @@ -1585,24 +1700,30 @@ class _TrackMetadataScreenState extends ConsumerState { if (mounted) { if (result['error'] != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed(result['error'].toString()), + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + context.l10n.trackSaveFailed(result['error'].toString()), + ), ), - ), - ); + ); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackLyricsSaved(baseName))), - ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(context.l10n.trackLyricsSaved(baseName))), + ); } } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), - ); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), + ); } } } @@ -1662,6 +1783,7 @@ class _TrackMetadataScreenState extends ConsumerState { if (method == 'native') { // FLAC - handled natively by Go (SAF write-back handled in Kotlin) + await _refreshEmbeddedCoverPreview(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.trackReEnrichSuccess)), @@ -1674,7 +1796,30 @@ class _TrackMetadataScreenState extends ConsumerState { final safUri = result['saf_uri'] as String?; final ffmpegTarget = tempPath ?? cleanFilePath; - final coverPath = result['cover_path'] as String?; + final downloadedCoverPath = result['cover_path'] as String?; + String? effectiveCoverPath = downloadedCoverPath; + String? extractedCoverPath; + if (!_hasPath(effectiveCoverPath)) { + try { + final tempDir = await Directory.systemTemp.createTemp( + 'reenrich_cover_', + ); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final extracted = await PlatformBridge.extractCoverToFile( + ffmpegTarget, + coverOutput, + ); + if (extracted['error'] == null) { + effectiveCoverPath = coverOutput; + extractedCoverPath = coverOutput; + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + } final metadata = (result['metadata'] as Map?)?.map( (k, v) => MapEntry(k, v.toString()), ); @@ -1684,13 +1829,13 @@ class _TrackMetadataScreenState extends ConsumerState { if (lower.endsWith('.mp3')) { ffmpegResult = await FFmpegService.embedMetadataToMp3( mp3Path: ffmpegTarget, - coverPath: coverPath, + coverPath: effectiveCoverPath, metadata: metadata, ); } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { ffmpegResult = await FFmpegService.embedMetadataToOpus( opusPath: ffmpegTarget, - coverPath: coverPath, + coverPath: effectiveCoverPath, metadata: metadata, ); } @@ -1709,11 +1854,14 @@ class _TrackMetadataScreenState extends ConsumerState { ), ); // Cleanup temp files - if (coverPath != null && coverPath.isNotEmpty) { + if (_hasPath(downloadedCoverPath)) { try { - await File(coverPath).delete(); + await File(downloadedCoverPath!).delete(); } catch (_) {} } + if (_hasPath(extractedCoverPath)) { + await _cleanupTempFileAndParent(extractedCoverPath); + } if (tempPath.isNotEmpty) { try { await File(tempPath).delete(); @@ -1730,24 +1878,28 @@ class _TrackMetadataScreenState extends ConsumerState { } catch (_) {} } - if (mounted) { - if (ffmpegResult != null) { + if (ffmpegResult != null) { + await _refreshEmbeddedCoverPreview(); + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.trackReEnrichSuccess)), ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackReEnrichFfmpegFailed)), - ); } + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackReEnrichFfmpegFailed)), + ); } // Cleanup temp cover from Go backend - if (coverPath != null && coverPath.isNotEmpty) { + if (_hasPath(downloadedCoverPath)) { try { - await File(coverPath).delete(); + await File(downloadedCoverPath!).delete(); } catch (_) {} } + if (_hasPath(extractedCoverPath)) { + await _cleanupTempFileAndParent(extractedCoverPath); + } } else { if (mounted) { final error = result['error']?.toString() ?? 'Unknown error'; @@ -2531,6 +2683,7 @@ class _TrackMetadataScreenState extends ConsumerState { } catch (_) { setState(() {}); } + await _refreshEmbeddedCoverPreview(); } } @@ -2708,6 +2861,9 @@ class _EditMetadataSheet extends StatefulWidget { class _EditMetadataSheetState extends State<_EditMetadataSheet> { bool _saving = false; bool _showAdvanced = false; + String? _selectedCoverPath; + String? _selectedCoverTempDir; + String? _selectedCoverName; late final TextEditingController _titleCtrl; late final TextEditingController _artistCtrl; @@ -2723,6 +2879,117 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { late final TextEditingController _composerCtrl; late final TextEditingController _commentCtrl; + String _resolveImageExtension(String? ext, Uint8List? bytes) { + final normalized = (ext ?? '').toLowerCase(); + if (normalized == 'png' || + normalized == 'jpg' || + normalized == 'jpeg' || + normalized == 'webp') { + return normalized == 'jpeg' ? 'jpg' : normalized; + } + if (bytes != null && bytes.length >= 8) { + if (bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return 'png'; + } + if (bytes[0] == 0xFF && bytes[1] == 0xD8) { + return 'jpg'; + } + if (bytes.length >= 12 && + bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46 && + bytes[8] == 0x57 && + bytes[9] == 0x45 && + bytes[10] == 0x42 && + bytes[11] == 0x50) { + return 'webp'; + } + } + return 'jpg'; + } + + Future _cleanupSelectedCoverTemp() async { + final dirPath = _selectedCoverTempDir; + _selectedCoverPath = null; + _selectedCoverTempDir = null; + _selectedCoverName = null; + if (dirPath == null || dirPath.isEmpty) return; + try { + final dir = Directory(dirPath); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } catch (_) {} + } + + void _cleanupSelectedCoverTempSync() { + final dirPath = _selectedCoverTempDir; + _selectedCoverPath = null; + _selectedCoverTempDir = null; + _selectedCoverName = null; + if (dirPath == null || dirPath.isEmpty) return; + try { + final dir = Directory(dirPath); + if (dir.existsSync()) { + dir.deleteSync(recursive: true); + } + } catch (_) {} + } + + Future _pickCoverImage() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: false, + withData: true, + ); + if (result == null || result.files.isEmpty) return; + + final picked = result.files.first; + final bytes = picked.bytes; + final sourcePath = picked.path; + final extension = _resolveImageExtension(picked.extension, bytes); + + final tempDir = await Directory.systemTemp.createTemp('edit_cover_'); + final tempPath = + '${tempDir.path}${Platform.pathSeparator}cover.$extension'; + + if (bytes != null && bytes.isNotEmpty) { + await File(tempPath).writeAsBytes(bytes, flush: true); + } else if (sourcePath != null && sourcePath.isNotEmpty) { + final sourceFile = File(sourcePath); + if (!await sourceFile.exists()) { + throw Exception('Selected image is not accessible'); + } + await sourceFile.copy(tempPath); + } else { + throw Exception('Unable to read selected image'); + } + + await _cleanupSelectedCoverTemp(); + if (!mounted) { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + return; + } + setState(() { + _selectedCoverPath = tempPath; + _selectedCoverTempDir = tempDir.path; + _selectedCoverName = picked.name; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to pick cover: $e'))); + } + } + @override void initState() { super.initState(); @@ -2744,6 +3011,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { @override void dispose() { + _cleanupSelectedCoverTempSync(); _titleCtrl.dispose(); _artistCtrl.dispose(); _albumCtrl.dispose(); @@ -2777,6 +3045,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { 'copyright': _copyrightCtrl.text, 'composer': _composerCtrl.text, 'comment': _commentCtrl.text, + 'cover_path': _selectedCoverPath ?? '', }; try { @@ -2851,21 +3120,29 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { 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; + String? existingCoverPath = _selectedCoverPath; + String? extractedCoverPath; + if (existingCoverPath == null || existingCoverPath.isEmpty) { + // Preserve current embedded cover when user does not pick a new one. + try { + final tempDir = await Directory.systemTemp.createTemp('cover_'); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + ffmpegTarget, + coverOutput, + ); + if (coverResult['error'] == null) { + existingCoverPath = coverOutput; + extractedCoverPath = coverOutput; + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) { + // No cover to preserve, continue without } - } catch (_) { - // No cover to preserve, continue without } String? ffmpegResult; @@ -2883,10 +3160,17 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); } - // Cleanup temp cover - if (existingCoverPath != null) { + // Cleanup extracted temp cover (manual selected cover is cleaned on dispose) + if (extractedCoverPath != null && extractedCoverPath.isNotEmpty) { + final extractedFile = File(extractedCoverPath); try { - await File(existingCoverPath).delete(); + await extractedFile.delete(); + } catch (_) {} + try { + final dir = extractedFile.parent; + if (await dir.exists()) { + await dir.delete(recursive: true); + } } catch (_) {} } @@ -3016,6 +3300,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), _field('Genre', _genreCtrl), _field('ISRC', _isrcCtrl), + _buildCoverEditor(cs), // Advanced fields toggle Padding( padding: const EdgeInsets.only(top: 8, bottom: 4), @@ -3061,6 +3346,88 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); } + Widget _buildCoverEditor(ColorScheme cs) { + final hasSelectedCover = + _selectedCoverPath != null && _selectedCoverPath!.isNotEmpty; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Cover Art', + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: cs.onSurface), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _saving ? null : _pickCoverImage, + icon: const Icon(Icons.image_outlined), + label: Text( + hasSelectedCover ? 'Replace Cover' : 'Pick Cover', + ), + ), + ), + if (hasSelectedCover) ...[ + const SizedBox(width: 8), + IconButton( + tooltip: 'Clear selected cover', + onPressed: _saving + ? null + : () async { + await _cleanupSelectedCoverTemp(); + if (!mounted) return; + setState(() {}); + }, + icon: const Icon(Icons.close), + ), + ], + ], + ), + if (hasSelectedCover) ...[ + const SizedBox(height: 8), + Text( + _selectedCoverName ?? 'Selected cover', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.file( + File(_selectedCoverPath!), + height: 120, + width: 120, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: 120, + height: 120, + color: cs.surfaceContainerHighest, + child: Icon(Icons.broken_image, color: cs.onSurfaceVariant), + ), + ), + ), + ], + ], + ), + ), + ); + } + Widget _field( String label, TextEditingController controller, {