fix: various improvements and fixes

This commit is contained in:
zarzet
2026-02-11 00:22:48 +07:00
parent fe1c96ea12
commit bd42655c0e
20 changed files with 634 additions and 108 deletions
@@ -1725,7 +1725,7 @@ class MainActivity: FlutterFragmentActivity() {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: ""
val durationMs = call.argument<Long>("duration_ms") ?: 0L
val durationMs = call.argument<Number>("duration_ms")?.toLong() ?: 0L
val outputPath = call.argument<String>("output_path") ?: ""
val response = withContext(Dispatchers.IO) {
try {
+55 -12
View File
@@ -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")
+89 -22
View File
@@ -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)
+6
View File
@@ -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:
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -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';
+2
View File
@@ -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",
+7 -5
View File
@@ -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"},
+435 -68
View File
@@ -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<TrackMetadataScreen> {
bool _isInstrumental = false; // Track if detected as instrumental
bool _isConverting = false; // Track convert operation in progress
Map<String, dynamic>? _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<TrackMetadataScreen> {
@override
void dispose() {
_cleanupTempFileAndParentSync(_embeddedCoverPreviewPath);
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
@@ -124,6 +127,82 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
}
bool _hasPath(String? path) => path != null && path.trim().isNotEmpty;
Future<void> _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<void> _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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
),
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
} 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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<String, dynamic>?)?.map(
(k, v) => MapEntry(k, v.toString()),
);
@@ -1684,13 +1829,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
),
);
// 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<TrackMetadataScreen> {
} 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<TrackMetadataScreen> {
} 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<void> _cleanupSelectedCoverTemp() async {
final dirPath = _selectedCoverTempDir;
_selectedCoverPath = null;
_selectedCoverTempDir = null;
_selectedCoverName = null;
if (dirPath == null || dirPath.isEmpty) return;
try {
final dir = Directory(dirPath);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
} catch (_) {}
}
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<void> _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, {