diff --git a/go_backend/exports.go b/go_backend/exports.go index fdeedac7..26502e8e 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -118,25 +118,40 @@ type DownloadResult struct { } type reEnrichRequest struct { - FilePath string `json:"file_path"` - CoverURL string `json:"cover_url"` - MaxQuality bool `json:"max_quality"` - EmbedLyrics bool `json:"embed_lyrics"` - ArtistTagMode string `json:"artist_tag_mode,omitempty"` - SpotifyID string `json:"spotify_id"` - TrackName string `json:"track_name"` - ArtistName string `json:"artist_name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist"` - TrackNumber int `json:"track_number"` - DiscNumber int `json:"disc_number"` - ReleaseDate string `json:"release_date"` - ISRC string `json:"isrc"` - Genre string `json:"genre"` - Label string `json:"label"` - Copyright string `json:"copyright"` - DurationMs int64 `json:"duration_ms"` - SearchOnline bool `json:"search_online"` + FilePath string `json:"file_path"` + CoverURL string `json:"cover_url"` + MaxQuality bool `json:"max_quality"` + EmbedLyrics bool `json:"embed_lyrics"` + ArtistTagMode string `json:"artist_tag_mode,omitempty"` + SpotifyID string `json:"spotify_id"` + TrackName string `json:"track_name"` + ArtistName string `json:"artist_name"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist"` + TrackNumber int `json:"track_number"` + DiscNumber int `json:"disc_number"` + ReleaseDate string `json:"release_date"` + ISRC string `json:"isrc"` + Genre string `json:"genre"` + Label string `json:"label"` + Copyright string `json:"copyright"` + DurationMs int64 `json:"duration_ms"` + SearchOnline bool `json:"search_online"` + UpdateFields []string `json:"update_fields,omitempty"` +} + +// shouldUpdateField returns true if the given field group should be updated. +// When UpdateFields is empty/nil, all fields are updated (backward compatible). +func (r *reEnrichRequest) shouldUpdateField(field string) bool { + if len(r.UpdateFields) == 0 { + return true + } + for _, f := range r.UpdateFields { + if f == field { + return true + } + } + return false } func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) { @@ -156,38 +171,52 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) { req.SpotifyID = track.ID } - if track.AlbumName != "" { - req.AlbumName = track.AlbumName + if req.shouldUpdateField("basic_tags") { + // Title and Artist are not overwritten — they are used for search matching + // and should remain as the user's original values. } - if track.AlbumArtist != "" { - req.AlbumArtist = track.AlbumArtist + if req.shouldUpdateField("basic_tags") { + if track.AlbumName != "" { + req.AlbumName = track.AlbumName + } + if track.AlbumArtist != "" { + req.AlbumArtist = track.AlbumArtist + } } - if track.TrackNumber > 0 { - req.TrackNumber = track.TrackNumber + if req.shouldUpdateField("track_info") { + if track.TrackNumber > 0 { + req.TrackNumber = track.TrackNumber + } + if track.DiscNumber > 0 { + req.DiscNumber = track.DiscNumber + } } - if track.DiscNumber > 0 { - req.DiscNumber = track.DiscNumber + if req.shouldUpdateField("release_info") { + if track.ReleaseDate != "" { + req.ReleaseDate = track.ReleaseDate + } + if track.ISRC != "" { + req.ISRC = track.ISRC + } } - if track.ReleaseDate != "" { - req.ReleaseDate = track.ReleaseDate - } - if track.ISRC != "" { - req.ISRC = track.ISRC - } - if coverURL := track.ResolvedCoverURL(); coverURL != "" { - req.CoverURL = coverURL + if req.shouldUpdateField("cover") { + if coverURL := track.ResolvedCoverURL(); coverURL != "" { + req.CoverURL = coverURL + } } if track.DurationMS > 0 { req.DurationMs = int64(track.DurationMS) } - if track.Genre != "" { - req.Genre = track.Genre - } - if track.Label != "" { - req.Label = track.Label - } - if track.Copyright != "" { - req.Copyright = track.Copyright + if req.shouldUpdateField("extra") { + if track.Genre != "" { + req.Genre = track.Genre + } + if track.Label != "" { + req.Label = track.Label + } + if track.Copyright != "" { + req.Copyright = track.Copyright + } } } @@ -203,44 +232,48 @@ func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest { } } -func buildReEnrichFFmpegMetadata(req reEnrichRequest, lyricsLRC string) map[string]string { +func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string { metadata := map[string]string{} - if req.TrackName != "" { - metadata["TITLE"] = req.TrackName + if req.shouldUpdateField("basic_tags") { + if req.AlbumName != "" { + metadata["ALBUM"] = req.AlbumName + } + if req.AlbumArtist != "" { + metadata["ALBUMARTIST"] = req.AlbumArtist + } } - if req.ArtistName != "" { - metadata["ARTIST"] = req.ArtistName + if req.shouldUpdateField("release_info") { + if req.ReleaseDate != "" { + metadata["DATE"] = req.ReleaseDate + } + if req.ISRC != "" { + metadata["ISRC"] = req.ISRC + } } - if req.AlbumName != "" { - metadata["ALBUM"] = req.AlbumName + if req.shouldUpdateField("extra") { + if req.Genre != "" { + metadata["GENRE"] = req.Genre + } + if req.Label != "" { + metadata["ORGANIZATION"] = req.Label + } + if req.Copyright != "" { + metadata["COPYRIGHT"] = req.Copyright + } } - if req.AlbumArtist != "" { - metadata["ALBUMARTIST"] = req.AlbumArtist + if req.shouldUpdateField("track_info") { + if req.TrackNumber > 0 { + metadata["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber) + } + if req.DiscNumber > 0 { + metadata["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber) + } } - if req.ReleaseDate != "" { - metadata["DATE"] = req.ReleaseDate - } - if req.ISRC != "" { - metadata["ISRC"] = req.ISRC - } - if req.Genre != "" { - metadata["GENRE"] = req.Genre - } - if req.TrackNumber > 0 { - metadata["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber) - } - if req.DiscNumber > 0 { - metadata["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber) - } - if req.Label != "" { - metadata["ORGANIZATION"] = req.Label - } - if req.Copyright != "" { - metadata["COPYRIGHT"] = req.Copyright - } - if lyricsLRC != "" { - metadata["LYRICS"] = lyricsLRC - metadata["UNSYNCEDLYRICS"] = lyricsLRC + if req.shouldUpdateField("lyrics") { + if lyricsLRC != "" { + metadata["LYRICS"] = lyricsLRC + metadata["UNSYNCEDLYRICS"] = lyricsLRC + } } return metadata } @@ -2102,7 +2135,7 @@ func ReEnrichFile(requestJSON string) (string, error) { } // Try to get extended metadata from Deezer if not already set - if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") { + if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC) cancel() @@ -2136,7 +2169,7 @@ func ReEnrichFile(requestJSON string) (string, error) { // Download cover art to temp file var coverTempPath string var coverDataBytes []byte - if req.CoverURL != "" { + if req.CoverURL != "" && req.shouldUpdateField("cover") { coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality) if err != nil { GoLog("[ReEnrich] Failed to download cover: %v\n", err) @@ -2186,14 +2219,16 @@ func ReEnrichFile(requestJSON string) (string, error) { // Preserve existing lyrics when online enrichment does not return a replacement. var lyricsLRC string - existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath) - if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" { - lyricsLRC = existingLyrics - GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n") + if req.shouldUpdateField("lyrics") { + existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath) + if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" { + lyricsLRC = existingLyrics + GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n") + } } // Fetch lyrics - if req.EmbedLyrics { + if req.EmbedLyrics && req.shouldUpdateField("lyrics") { client := NewLyricsClient() durationSec := float64(req.DurationMs) / 1000.0 lyrics, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, durationSec) @@ -2207,39 +2242,61 @@ func ReEnrichFile(requestJSON string) (string, error) { } } + // Build enrichedMeta map: only include fields from selected update groups + // so that the caller (Dart) does not overwrite non-selected metadata in its + // local library database with potentially stale cached values. enrichedMeta := map[string]interface{}{ - "track_name": req.TrackName, - "artist_name": req.ArtistName, - "album_name": req.AlbumName, - "album_artist": req.AlbumArtist, - "release_date": req.ReleaseDate, - "track_number": req.TrackNumber, - "disc_number": req.DiscNumber, - "isrc": req.ISRC, - "genre": req.Genre, - "label": req.Label, - "copyright": req.Copyright, - "cover_url": req.CoverURL, - "spotify_id": req.SpotifyID, - "duration_ms": req.DurationMs, + "spotify_id": req.SpotifyID, + "duration_ms": req.DurationMs, + } + if req.shouldUpdateField("basic_tags") { + enrichedMeta["album_name"] = req.AlbumName + enrichedMeta["album_artist"] = req.AlbumArtist + } + if req.shouldUpdateField("track_info") { + enrichedMeta["track_number"] = req.TrackNumber + enrichedMeta["disc_number"] = req.DiscNumber + } + if req.shouldUpdateField("release_info") { + enrichedMeta["release_date"] = req.ReleaseDate + enrichedMeta["isrc"] = req.ISRC + } + if req.shouldUpdateField("cover") { + enrichedMeta["cover_url"] = req.CoverURL + } + if req.shouldUpdateField("extra") { + enrichedMeta["genre"] = req.Genre + enrichedMeta["label"] = req.Label + enrichedMeta["copyright"] = req.Copyright } if isFlac { - // Native Go FLAC metadata embedding + // Native Go FLAC metadata embedding. + // Only populate Metadata fields for selected update groups; empty/zero + // values cause EmbedMetadata's setComment() to skip those tags, + // preserving whatever is already in the file. metadata := Metadata{ - Title: req.TrackName, - Artist: req.ArtistName, - Album: req.AlbumName, - AlbumArtist: req.AlbumArtist, ArtistTagMode: req.ArtistTagMode, - Date: req.ReleaseDate, - TrackNumber: req.TrackNumber, - DiscNumber: req.DiscNumber, - ISRC: req.ISRC, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, - Lyrics: lyricsLRC, + } + if req.shouldUpdateField("basic_tags") { + metadata.Album = req.AlbumName + metadata.AlbumArtist = req.AlbumArtist + } + if req.shouldUpdateField("track_info") { + metadata.TrackNumber = req.TrackNumber + metadata.DiscNumber = req.DiscNumber + } + if req.shouldUpdateField("release_info") { + metadata.Date = req.ReleaseDate + metadata.ISRC = req.ISRC + } + if req.shouldUpdateField("lyrics") { + metadata.Lyrics = lyricsLRC + } + if req.shouldUpdateField("extra") { + metadata.Genre = req.Genre + metadata.Label = req.Label + metadata.Copyright = req.Copyright } if len(coverDataBytes) > 0 { @@ -2275,7 +2332,7 @@ func ReEnrichFile(requestJSON string) (string, error) { // Don't cleanup cover temp — Dart needs it for FFmpeg embed cleanupCover = false - ffmpegMetadata := buildReEnrichFFmpegMetadata(req, lyricsLRC) + ffmpegMetadata := buildReEnrichFFmpegMetadata(&req, lyricsLRC) result := map[string]interface{}{ "method": "ffmpeg", diff --git a/go_backend/exports_test.go b/go_backend/exports_test.go index 15a4bbb3..32cce99f 100644 --- a/go_backend/exports_test.go +++ b/go_backend/exports_test.go @@ -193,13 +193,15 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) { Copyright: "", } - metadata := buildReEnrichFFmpegMetadata(req, "") + metadata := buildReEnrichFFmpegMetadata(&req, "") - if metadata["TITLE"] != "Song" { - t.Fatalf("title = %q", metadata["TITLE"]) + // Title and Artist are never written by re-enrich (they are search keys + // preserved as-is from the file). + if _, exists := metadata["TITLE"]; exists { + t.Fatalf("TITLE should not be in metadata: %#v", metadata) } - if metadata["ARTIST"] != "Artist" { - t.Fatalf("artist = %q", metadata["ARTIST"]) + if _, exists := metadata["ARTIST"]; exists { + t.Fatalf("ARTIST should not be in metadata: %#v", metadata) } if metadata["ALBUM"] != "Album" { t.Fatalf("album = %q", metadata["ALBUM"]) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d811ec9b..6559d666 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4048,6 +4048,54 @@ abstract class AppLocalizations { /// **'Search metadata online and embed into file'** String get trackReEnrichOnlineSubtitle; + /// Section title for field selection in re-enrich dialog + /// + /// In en, this message translates to: + /// **'Fields to update'** + String get trackReEnrichFieldsTitle; + + /// Checkbox label for cover art field in re-enrich + /// + /// In en, this message translates to: + /// **'Cover Art'** + String get trackReEnrichFieldCover; + + /// Checkbox label for lyrics field in re-enrich + /// + /// In en, this message translates to: + /// **'Lyrics'** + String get trackReEnrichFieldLyrics; + + /// Checkbox label for basic tags in re-enrich (title/artist are never overwritten) + /// + /// In en, this message translates to: + /// **'Album, Album Artist'** + String get trackReEnrichFieldBasicTags; + + /// Checkbox label for track info in re-enrich + /// + /// In en, this message translates to: + /// **'Track & Disc Number'** + String get trackReEnrichFieldTrackInfo; + + /// Checkbox label for release info in re-enrich + /// + /// In en, this message translates to: + /// **'Date & ISRC'** + String get trackReEnrichFieldReleaseInfo; + + /// Checkbox label for extra metadata in re-enrich + /// + /// In en, this message translates to: + /// **'Genre, Label, Copyright'** + String get trackReEnrichFieldExtra; + + /// Select all fields checkbox in re-enrich + /// + /// In en, this message translates to: + /// **'Select All'** + String get trackReEnrichSelectAll; + /// Menu action - edit embedded metadata /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 1ba2aa2b..9ef37991 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2290,6 +2290,30 @@ class AppLocalizationsDe extends AppLocalizations { String get trackReEnrichOnlineSubtitle => 'Metadaten online suchen und in Datei einbinden'; + @override + String get trackReEnrichFieldsTitle => 'Fields to update'; + + @override + String get trackReEnrichFieldCover => 'Cover Art'; + + @override + String get trackReEnrichFieldLyrics => 'Lyrics'; + + @override + String get trackReEnrichFieldBasicTags => 'Album, Album Artist'; + + @override + String get trackReEnrichFieldTrackInfo => 'Track & Disc Number'; + + @override + String get trackReEnrichFieldReleaseInfo => 'Date & ISRC'; + + @override + String get trackReEnrichFieldExtra => 'Genre, Label, Copyright'; + + @override + String get trackReEnrichSelectAll => 'Select All'; + @override String get trackEditMetadata => 'Metadaten bearbeiten'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 50bf5a0a..7cc3d8a7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2260,6 +2260,30 @@ class AppLocalizationsEn extends AppLocalizations { String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; + @override + String get trackReEnrichFieldsTitle => 'Fields to update'; + + @override + String get trackReEnrichFieldCover => 'Cover Art'; + + @override + String get trackReEnrichFieldLyrics => 'Lyrics'; + + @override + String get trackReEnrichFieldBasicTags => 'Album, Album Artist'; + + @override + String get trackReEnrichFieldTrackInfo => 'Track & Disc Number'; + + @override + String get trackReEnrichFieldReleaseInfo => 'Date & ISRC'; + + @override + String get trackReEnrichFieldExtra => 'Genre, Label, Copyright'; + + @override + String get trackReEnrichSelectAll => 'Select All'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 9c6a9988..661553a2 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2260,6 +2260,30 @@ class AppLocalizationsEs extends AppLocalizations { String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; + @override + String get trackReEnrichFieldsTitle => 'Fields to update'; + + @override + String get trackReEnrichFieldCover => 'Cover Art'; + + @override + String get trackReEnrichFieldLyrics => 'Lyrics'; + + @override + String get trackReEnrichFieldBasicTags => 'Album, Album Artist'; + + @override + String get trackReEnrichFieldTrackInfo => 'Track & Disc Number'; + + @override + String get trackReEnrichFieldReleaseInfo => 'Date & ISRC'; + + @override + String get trackReEnrichFieldExtra => 'Genre, Label, Copyright'; + + @override + String get trackReEnrichSelectAll => 'Select All'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 0a9d7722..c50f8bf2 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2262,6 +2262,30 @@ class AppLocalizationsFr extends AppLocalizations { String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; + @override + String get trackReEnrichFieldsTitle => 'Fields to update'; + + @override + String get trackReEnrichFieldCover => 'Cover Art'; + + @override + String get trackReEnrichFieldLyrics => 'Lyrics'; + + @override + String get trackReEnrichFieldBasicTags => 'Album, Album Artist'; + + @override + String get trackReEnrichFieldTrackInfo => 'Track & Disc Number'; + + @override + String get trackReEnrichFieldReleaseInfo => 'Date & ISRC'; + + @override + String get trackReEnrichFieldExtra => 'Genre, Label, Copyright'; + + @override + String get trackReEnrichSelectAll => 'Select All'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 460510f4..dcd5d24f 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2260,6 +2260,30 @@ class AppLocalizationsHi extends AppLocalizations { String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; + @override + String get trackReEnrichFieldsTitle => 'Fields to update'; + + @override + String get trackReEnrichFieldCover => 'Cover Art'; + + @override + String get trackReEnrichFieldLyrics => 'Lyrics'; + + @override + String get trackReEnrichFieldBasicTags => 'Album, Album Artist'; + + @override + String get trackReEnrichFieldTrackInfo => 'Track & Disc Number'; + + @override + String get trackReEnrichFieldReleaseInfo => 'Date & ISRC'; + + @override + String get trackReEnrichFieldExtra => 'Genre, Label, Copyright'; + + @override + String get trackReEnrichSelectAll => 'Select All'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 3dc9058b..1c1d0c1b 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2270,6 +2270,30 @@ class AppLocalizationsId extends AppLocalizations { String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; + @override + String get trackReEnrichFieldsTitle => 'Fields to update'; + + @override + String get trackReEnrichFieldCover => 'Cover Art'; + + @override + String get trackReEnrichFieldLyrics => 'Lyrics'; + + @override + String get trackReEnrichFieldBasicTags => 'Album, Album Artist'; + + @override + String get trackReEnrichFieldTrackInfo => 'Track & Disc Number'; + + @override + String get trackReEnrichFieldReleaseInfo => 'Date & ISRC'; + + @override + String get trackReEnrichFieldExtra => 'Genre, Label, Copyright'; + + @override + String get trackReEnrichSelectAll => 'Select All'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index b0cd22b4..06cc84af 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2247,6 +2247,30 @@ class AppLocalizationsJa extends AppLocalizations { String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; + @override + String get trackReEnrichFieldsTitle => 'Fields to update'; + + @override + String get trackReEnrichFieldCover => 'Cover Art'; + + @override + String get trackReEnrichFieldLyrics => 'Lyrics'; + + @override + String get trackReEnrichFieldBasicTags => 'Album, Album Artist'; + + @override + String get trackReEnrichFieldTrackInfo => 'Track & Disc Number'; + + @override + String get trackReEnrichFieldReleaseInfo => 'Date & ISRC'; + + @override + String get trackReEnrichFieldExtra => 'Genre, Label, Copyright'; + + @override + String get trackReEnrichSelectAll => 'Select All'; + @override String get trackEditMetadata => 'メタデータを編集'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 67e594d0..cd06f44d 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2240,6 +2240,30 @@ class AppLocalizationsKo extends AppLocalizations { String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; + @override + String get trackReEnrichFieldsTitle => 'Fields to update'; + + @override + String get trackReEnrichFieldCover => 'Cover Art'; + + @override + String get trackReEnrichFieldLyrics => 'Lyrics'; + + @override + String get trackReEnrichFieldBasicTags => 'Album, Album Artist'; + + @override + String get trackReEnrichFieldTrackInfo => 'Track & Disc Number'; + + @override + String get trackReEnrichFieldReleaseInfo => 'Date & ISRC'; + + @override + String get trackReEnrichFieldExtra => 'Genre, Label, Copyright'; + + @override + String get trackReEnrichSelectAll => 'Select All'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 7681f859..f04b17e6 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2260,6 +2260,30 @@ class AppLocalizationsNl extends AppLocalizations { String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; + @override + String get trackReEnrichFieldsTitle => 'Fields to update'; + + @override + String get trackReEnrichFieldCover => 'Cover Art'; + + @override + String get trackReEnrichFieldLyrics => 'Lyrics'; + + @override + String get trackReEnrichFieldBasicTags => 'Album, Album Artist'; + + @override + String get trackReEnrichFieldTrackInfo => 'Track & Disc Number'; + + @override + String get trackReEnrichFieldReleaseInfo => 'Date & ISRC'; + + @override + String get trackReEnrichFieldExtra => 'Genre, Label, Copyright'; + + @override + String get trackReEnrichSelectAll => 'Select All'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 33d34cd3..3b023ae9 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2260,6 +2260,30 @@ class AppLocalizationsPt extends AppLocalizations { String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; + @override + String get trackReEnrichFieldsTitle => 'Fields to update'; + + @override + String get trackReEnrichFieldCover => 'Cover Art'; + + @override + String get trackReEnrichFieldLyrics => 'Lyrics'; + + @override + String get trackReEnrichFieldBasicTags => 'Album, Album Artist'; + + @override + String get trackReEnrichFieldTrackInfo => 'Track & Disc Number'; + + @override + String get trackReEnrichFieldReleaseInfo => 'Date & ISRC'; + + @override + String get trackReEnrichFieldExtra => 'Genre, Label, Copyright'; + + @override + String get trackReEnrichSelectAll => 'Select All'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index fd5a195e..c5c61527 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2312,6 +2312,30 @@ class AppLocalizationsRu extends AppLocalizations { String get trackReEnrichOnlineSubtitle => 'Поиск в сети метаданных и встраивание в файл'; + @override + String get trackReEnrichFieldsTitle => 'Fields to update'; + + @override + String get trackReEnrichFieldCover => 'Cover Art'; + + @override + String get trackReEnrichFieldLyrics => 'Lyrics'; + + @override + String get trackReEnrichFieldBasicTags => 'Album, Album Artist'; + + @override + String get trackReEnrichFieldTrackInfo => 'Track & Disc Number'; + + @override + String get trackReEnrichFieldReleaseInfo => 'Date & ISRC'; + + @override + String get trackReEnrichFieldExtra => 'Genre, Label, Copyright'; + + @override + String get trackReEnrichSelectAll => 'Select All'; + @override String get trackEditMetadata => 'Редактировать метаданные'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index bd86162e..25d24804 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2266,6 +2266,30 @@ class AppLocalizationsTr extends AppLocalizations { String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; + @override + String get trackReEnrichFieldsTitle => 'Fields to update'; + + @override + String get trackReEnrichFieldCover => 'Cover Art'; + + @override + String get trackReEnrichFieldLyrics => 'Lyrics'; + + @override + String get trackReEnrichFieldBasicTags => 'Album, Album Artist'; + + @override + String get trackReEnrichFieldTrackInfo => 'Track & Disc Number'; + + @override + String get trackReEnrichFieldReleaseInfo => 'Date & ISRC'; + + @override + String get trackReEnrichFieldExtra => 'Genre, Label, Copyright'; + + @override + String get trackReEnrichSelectAll => 'Select All'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 5b9024bd..b738f4ec 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2260,6 +2260,30 @@ class AppLocalizationsZh extends AppLocalizations { String get trackReEnrichOnlineSubtitle => 'Search metadata online and embed into file'; + @override + String get trackReEnrichFieldsTitle => 'Fields to update'; + + @override + String get trackReEnrichFieldCover => 'Cover Art'; + + @override + String get trackReEnrichFieldLyrics => 'Lyrics'; + + @override + String get trackReEnrichFieldBasicTags => 'Album, Album Artist'; + + @override + String get trackReEnrichFieldTrackInfo => 'Track & Disc Number'; + + @override + String get trackReEnrichFieldReleaseInfo => 'Date & ISRC'; + + @override + String get trackReEnrichFieldExtra => 'Genre, Label, Copyright'; + + @override + String get trackReEnrichSelectAll => 'Select All'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 1a219977..5e78bbee 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2954,6 +2954,38 @@ "@trackReEnrichOnlineSubtitle": { "description": "Subtitle for re-enrich metadata action for local items" }, + "trackReEnrichFieldsTitle": "Fields to update", + "@trackReEnrichFieldsTitle": { + "description": "Section title for field selection in re-enrich dialog" + }, + "trackReEnrichFieldCover": "Cover Art", + "@trackReEnrichFieldCover": { + "description": "Checkbox label for cover art field in re-enrich" + }, + "trackReEnrichFieldLyrics": "Lyrics", + "@trackReEnrichFieldLyrics": { + "description": "Checkbox label for lyrics field in re-enrich" + }, + "trackReEnrichFieldBasicTags": "Album, Album Artist", + "@trackReEnrichFieldBasicTags": { + "description": "Checkbox label for basic tags in re-enrich (title/artist are never overwritten)" + }, + "trackReEnrichFieldTrackInfo": "Track & Disc Number", + "@trackReEnrichFieldTrackInfo": { + "description": "Checkbox label for track info in re-enrich" + }, + "trackReEnrichFieldReleaseInfo": "Date & ISRC", + "@trackReEnrichFieldReleaseInfo": { + "description": "Checkbox label for release info in re-enrich" + }, + "trackReEnrichFieldExtra": "Genre, Label, Copyright", + "@trackReEnrichFieldExtra": { + "description": "Checkbox label for extra metadata in re-enrich" + }, + "trackReEnrichSelectAll": "Select All", + "@trackReEnrichSelectAll": { + "description": "Select all fields checkbox in re-enrich" + }, "trackEditMetadata": "Edit Metadata", "@trackEditMetadata": { "description": "Menu action - edit embedded metadata" diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index bc6e9191..61e5eac7 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -16,7 +16,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; -import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; @@ -1238,23 +1237,19 @@ class _CollectionTrackTile extends ConsumerWidget { trailing: isSelectionMode ? null : historyItem != null || localItem != null - ? IconButton( - tooltip: context.l10n.tooltipPlay, - onPressed: () { - ref - .read(playbackProvider.notifier) - .playTrackList([track]); - }, - icon: Icon( - Icons.play_arrow, - color: colorScheme.primary, - ), - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer - .withValues(alpha: 0.3), - ), - ) - : null, + ? IconButton( + tooltip: context.l10n.tooltipPlay, + onPressed: () { + ref.read(playbackProvider.notifier).playTrackList([track]); + }, + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer.withValues( + alpha: 0.3, + ), + ), + ) + : null, onTap: isSelectionMode ? onTap : () { @@ -1333,155 +1328,6 @@ class _CollectionTrackTile extends ConsumerWidget { ); } - void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) { - final track = entry.track; - final effectiveCoverUrl = _resolveCoverUrl(track); - final colorScheme = Theme.of(context).colorScheme; - final historyState = ref.read(downloadHistoryProvider); - final isDownloaded = - historyState.isDownloaded(track.id) || - (track.isrc != null && - track.isrc!.isNotEmpty && - historyState.getByIsrc(track.isrc!) != null) || - historyState.findByTrackAndArtist(track.name, track.artistName) != null; - // Wishlist: only show "Add to Playlist" if track is already downloaded - final showAddToPlaylist = - mode != LibraryTracksFolderMode.wishlist || isDownloaded; - - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Column( - children: [ - const SizedBox(height: 8), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: - effectiveCoverUrl != null && - effectiveCoverUrl.isNotEmpty - ? _buildTrackCover(context, effectiveCoverUrl, 56) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - track.name, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - track.artistName, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ), - ], - ), - Divider( - height: 1, - color: colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - - if (showAddToPlaylist) - BottomSheetOptionTile( - icon: Icons.playlist_add, - title: context.l10n.collectionAddToPlaylist, - onTap: () { - Navigator.pop(sheetContext); - showAddTrackToPlaylistSheet(context, ref, track); - }, - ), - - BottomSheetOptionTile( - icon: Icons.remove_circle_outline, - iconColor: colorScheme.error, - title: mode == LibraryTracksFolderMode.playlist - ? context.l10n.collectionRemoveFromPlaylist - : context.l10n.collectionRemoveFromFolder, - onTap: () { - Navigator.pop(sheetContext); - _removeFromCurrentFolder(context, ref); - }, - ), - - const SizedBox(height: 16), - ], - ), - ), - ); - } - - Future _removeFromCurrentFolder( - BuildContext context, - WidgetRef ref, - ) async { - final notifier = ref.read(libraryCollectionsProvider.notifier); - final key = entry.key; - - switch (mode) { - case LibraryTracksFolderMode.wishlist: - await notifier.removeFromWishlist(key); - break; - case LibraryTracksFolderMode.loved: - await notifier.removeFromLoved(key); - break; - case LibraryTracksFolderMode.playlist: - if (playlistId != null) { - await notifier.removeTrackFromPlaylist(playlistId!, key); - } - break; - } - - if (!context.mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.collectionRemoved(entry.track.name))), - ); - } - void _downloadTrack(BuildContext context, WidgetRef ref) { final track = entry.track; final settings = ref.read(settingsProvider); diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 3ad77b6b..9efc624f 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; +import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; @@ -824,12 +825,14 @@ class _LocalAlbumScreenState extends ConsumerState { mp3Path: ffmpegTarget, coverPath: effectiveCoverPath, metadata: metadata, + preserveMetadata: true, ); } else if (isM4A) { ffmpegResult = await FFmpegService.embedMetadataToM4a( m4aPath: ffmpegTarget, coverPath: effectiveCoverPath, metadata: metadata, + preserveMetadata: true, ); } else if (isOpus) { ffmpegResult = await FFmpegService.embedMetadataToOpus( @@ -837,6 +840,7 @@ class _LocalAlbumScreenState extends ConsumerState { coverPath: effectiveCoverPath, metadata: metadata, artistTagMode: artistTagMode, + preserveMetadata: true, ); } @@ -867,7 +871,10 @@ class _LocalAlbumScreenState extends ConsumerState { return ffmpegResult != null; } - Future _reEnrichLocalTrack(LocalLibraryItem item) async { + Future _reEnrichLocalTrack( + LocalLibraryItem item, { + List? updateFields, + }) async { final durationMs = (item.duration ?? 0) * 1000; final artistTagMode = ref.read(settingsProvider).artistTagMode; final request = { @@ -890,6 +897,8 @@ class _LocalAlbumScreenState extends ConsumerState { 'copyright': '', 'duration_ms': durationMs, 'search_online': true, + // ignore: use_null_aware_elements + if (updateFields != null) 'update_fields': updateFields, }; final result = await PlatformBridge.reEnrichFile(request); @@ -1048,31 +1057,25 @@ class _LocalAlbumScreenState extends ConsumerState { return; } - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.trackReEnrich), - content: Text( - '${context.l10n.trackReEnrichOnlineSubtitle}\n\n' - '${context.l10n.downloadedAlbumSelectedCount(selected.length)}', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(context.l10n.trackReEnrich), - ), - ], - ), + // Temporarily hide selection bar so it doesn't overlap the bottom sheet. + // The bar uses AnimatedPositioned (250ms), so wait for the slide-out. + setState(() => _isSelectionMode = false); + await Future.delayed(const Duration(milliseconds: 300)); + if (!mounted) return; + + final selection = await showReEnrichFieldDialog( + context, + selectedCount: selected.length, ); - if (confirmed != true || !mounted) { + if (selection == null || !mounted) { + // Cancelled — restore selection mode (IDs are still intact). + if (mounted) setState(() => _isSelectionMode = true); return; } + final updateFields = selection.isAll ? null : selection.fields; + var successCount = 0; final total = selected.length; @@ -1098,7 +1101,7 @@ class _LocalAlbumScreenState extends ConsumerState { ); try { - final ok = await _reEnrichLocalTrack(item); + final ok = await _reEnrichLocalTrack(item, updateFields: updateFields); if (ok) { successCount++; } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 898c5056..e1f7555f 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -329,6 +329,8 @@ class _MainShellState extends ConsumerState return; } + if (!mounted) return; + final trackState = ref.read(trackProvider); final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index f604ec14..4e0156a6 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -28,6 +28,7 @@ import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; +import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; @@ -4913,12 +4914,14 @@ class _QueueTabState extends ConsumerState { mp3Path: ffmpegTarget, coverPath: effectiveCoverPath, metadata: metadata, + preserveMetadata: true, ); } else if (isM4A) { ffmpegResult = await FFmpegService.embedMetadataToM4a( m4aPath: ffmpegTarget, coverPath: effectiveCoverPath, metadata: metadata, + preserveMetadata: true, ); } else if (isOpus) { ffmpegResult = await FFmpegService.embedMetadataToOpus( @@ -4926,6 +4929,7 @@ class _QueueTabState extends ConsumerState { coverPath: effectiveCoverPath, metadata: metadata, artistTagMode: artistTagMode, + preserveMetadata: true, ); } @@ -4958,7 +4962,10 @@ class _QueueTabState extends ConsumerState { return ffmpegResult != null; } - Future _reEnrichQueueLocalTrack(LocalLibraryItem item) async { + Future _reEnrichQueueLocalTrack( + LocalLibraryItem item, { + List? updateFields, + }) async { final durationMs = (item.duration ?? 0) * 1000; final artistTagMode = ref.read(settingsProvider).artistTagMode; final request = { @@ -4981,6 +4988,8 @@ class _QueueTabState extends ConsumerState { 'copyright': '', 'duration_ms': durationMs, 'search_online': true, + // ignore: use_null_aware_elements + if (updateFields != null) 'update_fields': updateFields, }; final result = await PlatformBridge.reEnrichFile(request); @@ -5144,31 +5153,25 @@ class _QueueTabState extends ConsumerState { return; } - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.trackReEnrich), - content: Text( - '${context.l10n.trackReEnrichOnlineSubtitle}\n\n' - '${context.l10n.downloadedAlbumSelectedCount(selectedLocalItems.length)}', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(context.l10n.trackReEnrich), - ), - ], - ), + // Hide the selection overlay: set the flag (prevents build() from + // re-inserting via postFrameCallback) and remove the entry immediately. + setState(() => _isSelectionMode = false); + _hideSelectionOverlay(); + + final selection = await showReEnrichFieldDialog( + context, + selectedCount: selectedLocalItems.length, ); - if (confirmed != true || !mounted) { + if (selection == null || !mounted) { + // Cancelled — restore selection mode; the next build cycle will + // re-create the overlay via _syncSelectionOverlay in postFrameCallback. + if (mounted) setState(() => _isSelectionMode = true); return; } + final updateFields = selection.isAll ? null : selection.fields; + var successCount = 0; final total = selectedLocalItems.length; @@ -5194,7 +5197,10 @@ class _QueueTabState extends ConsumerState { ); try { - final ok = await _reEnrichQueueLocalTrack(item); + final ok = await _reEnrichQueueLocalTrack( + item, + updateFields: updateFields, + ); if (ok) { successCount++; } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index b033b985..b1d9b2ec 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -331,7 +331,9 @@ class _TrackMetadataScreenState extends ConsumerState { setState(() { _editedMetadata = { ...?_editedMetadata, + // ignore: use_null_aware_elements if (resolvedBitDepth != null) 'bit_depth': resolvedBitDepth, + // ignore: use_null_aware_elements if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate, if (needsAlbum) 'album': resolvedAlbum, if (needsDuration) 'duration': resolvedDuration, diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 8355f66d..00987ebd 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -967,6 +967,7 @@ class FFmpegService { required String mp3Path, String? coverPath, Map? metadata, + bool preserveMetadata = false, }) async { final tempDir = await getTemporaryDirectory(); final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3'); @@ -979,7 +980,9 @@ class FFmpegService { } cmdBuffer.write('-map 0:a '); - cmdBuffer.write('-map_metadata -1 '); + cmdBuffer.write( + preserveMetadata ? '-map_metadata 0 ' : '-map_metadata -1 ', + ); if (coverPath != null) { cmdBuffer.write('-map 1:0 '); @@ -1050,18 +1053,20 @@ class FFmpegService { String? coverPath, Map? metadata, String artistTagMode = artistTagModeJoined, + bool preserveMetadata = false, }) async { final tempDir = await getTemporaryDirectory(); final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus'); + final mapMetaValue = preserveMetadata ? '0' : '-1'; final arguments = [ '-i', opusPath, '-map', '0:a', '-map_metadata', - '-1', + mapMetaValue, '-map_metadata:s:a', - '-1', + mapMetaValue, '-c:a', 'copy', ]; @@ -1140,6 +1145,7 @@ class FFmpegService { required String m4aPath, String? coverPath, Map? metadata, + bool preserveMetadata = false, }) async { final tempDir = await getTemporaryDirectory(); final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a'); @@ -1153,7 +1159,9 @@ class FFmpegService { } cmdBuffer.write('-map 0:a '); - cmdBuffer.write('-map_metadata -1 '); + cmdBuffer.write( + preserveMetadata ? '-map_metadata 0 ' : '-map_metadata -1 ', + ); // For M4A/MP4, cover art is mapped as a video stream and stored in the // 'covr' atom automatically by FFmpeg. The '-disposition attached_pic' diff --git a/lib/widgets/re_enrich_field_dialog.dart b/lib/widgets/re_enrich_field_dialog.dart new file mode 100644 index 00000000..b8f2bd8e --- /dev/null +++ b/lib/widgets/re_enrich_field_dialog.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import '../l10n/app_localizations.dart'; + +/// Field group keys matching the Go backend `update_fields` values. +class ReEnrichFields { + static const String cover = 'cover'; + static const String lyrics = 'lyrics'; + static const String basicTags = 'basic_tags'; + static const String trackInfo = 'track_info'; + static const String releaseInfo = 'release_info'; + static const String extra = 'extra'; + + static const List all = [ + cover, + lyrics, + basicTags, + trackInfo, + releaseInfo, + extra, + ]; +} + +/// Result returned by the re-enrich field selection sheet. +class ReEnrichFieldSelection { + final List fields; + const ReEnrichFieldSelection(this.fields); + + /// True when every available field is selected (or update_fields can be omitted). + bool get isAll => fields.length == ReEnrichFields.all.length; +} + +/// Shows a bottom sheet that lets the user pick which metadata fields to update +/// during a bulk re-enrich operation. +/// +/// Returns `null` when cancelled, or a [ReEnrichFieldSelection] when confirmed. +Future showReEnrichFieldDialog( + BuildContext context, { + required int selectedCount, +}) { + return showModalBottomSheet( + context: context, + useRootNavigator: true, + showDragHandle: true, + isScrollControlled: true, + builder: (ctx) => _ReEnrichFieldSheet(selectedCount: selectedCount), + ); +} + +class _ReEnrichFieldSheet extends StatefulWidget { + final int selectedCount; + const _ReEnrichFieldSheet({required this.selectedCount}); + + @override + State<_ReEnrichFieldSheet> createState() => _ReEnrichFieldSheetState(); +} + +class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { + final Set _selected = Set.from(ReEnrichFields.all); + + bool get _allSelected => _selected.length == ReEnrichFields.all.length; + + void _toggleAll(bool? value) { + setState(() { + if (value == true) { + _selected.addAll(ReEnrichFields.all); + } else { + _selected.clear(); + } + }); + } + + void _toggle(String field, bool? value) { + setState(() { + if (value == true) { + _selected.add(field); + } else { + _selected.remove(field); + } + }); + } + + String _labelFor(String field, AppLocalizations l10n) { + switch (field) { + case ReEnrichFields.cover: + return l10n.trackReEnrichFieldCover; + case ReEnrichFields.lyrics: + return l10n.trackReEnrichFieldLyrics; + case ReEnrichFields.basicTags: + return l10n.trackReEnrichFieldBasicTags; + case ReEnrichFields.trackInfo: + return l10n.trackReEnrichFieldTrackInfo; + case ReEnrichFields.releaseInfo: + return l10n.trackReEnrichFieldReleaseInfo; + case ReEnrichFields.extra: + return l10n.trackReEnrichFieldExtra; + default: + return field; + } + } + + IconData _iconFor(String field) { + switch (field) { + case ReEnrichFields.cover: + return Icons.image_outlined; + case ReEnrichFields.lyrics: + return Icons.lyrics_outlined; + case ReEnrichFields.basicTags: + return Icons.album_outlined; + case ReEnrichFields.trackInfo: + return Icons.format_list_numbered; + case ReEnrichFields.releaseInfo: + return Icons.calendar_today_outlined; + case ReEnrichFields.extra: + return Icons.label_outline; + default: + return Icons.tag; + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 4), + child: Text( + l10n.trackReEnrich, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + // Subtitle + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 4), + child: Text( + l10n.trackReEnrichOnlineSubtitle, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Text( + l10n.downloadedAlbumSelectedCount(widget.selectedCount), + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const Divider(height: 1), + // Select All + CheckboxListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + title: Text( + l10n.trackReEnrichSelectAll, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + value: _allSelected, + tristate: true, + onChanged: _toggleAll, + controlAffinity: ListTileControlAffinity.leading, + ), + const Divider(height: 1, indent: 16, endIndent: 16), + // Individual fields + for (final field in ReEnrichFields.all) + CheckboxListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + secondary: Icon(_iconFor(field), size: 20), + title: Text(_labelFor(field, l10n)), + value: _selected.contains(field), + onChanged: (v) => _toggle(field, v), + controlAffinity: ListTileControlAffinity.leading, + ), + const SizedBox(height: 8), + // Confirm button + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _selected.isEmpty + ? null + : () => Navigator.pop( + context, + ReEnrichFieldSelection(_selected.toList()), + ), + icon: const Icon(Icons.auto_fix_high, size: 18), + label: Text(l10n.trackReEnrich), + ), + ), + ), + ], + ), + ); + } +}