mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-15 05:10:28 +02:00
fix: various improvements and fixes
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user