mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-26 01:27:47 +02:00
feat: add field selection dialog for bulk re-enrich metadata
Add a bottom sheet dialog that lets users choose which metadata field groups to update during bulk re-enrich (cover, lyrics, album/album artist, track/disc number, date/ISRC, genre/label/copyright). Backend (Go): - Filter FLAC Metadata struct and FFmpeg metadata map by selected update_fields so non-selected groups preserve existing file values - Guard Deezer extended metadata fetch with shouldUpdateField(extra) - Title/Artist are never overwritten by re-enrich (search keys only) - enrichedMeta response only includes selected field groups Frontend (Dart): - New re_enrich_field_dialog.dart bottom sheet with checkboxes - FFmpegService embed methods gain preserveMetadata param that uses -map_metadata 0 instead of -1 to preserve non-selected tags - Hide selection overlay/bar before showing dialog, restore on cancel - Fix setState-after-dispose guard in cancel branches Cleanup: - Remove dead code in library_tracks_folder_screen.dart - Fix use_build_context_synchronously in main_shell.dart - Suppress false-positive use_null_aware_elements lints - Update l10n label from 'Title, Artist, Album' to 'Album, Album Artist'
This commit is contained in:
+168
-111
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'メタデータを編集';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Редактировать метаданные';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<void>(
|
||||
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<void> _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);
|
||||
|
||||
@@ -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<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -867,7 +871,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return ffmpegResult != null;
|
||||
}
|
||||
|
||||
Future<bool> _reEnrichLocalTrack(LocalLibraryItem item) async {
|
||||
Future<bool> _reEnrichLocalTrack(
|
||||
LocalLibraryItem item, {
|
||||
List<String>? updateFields,
|
||||
}) async {
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
final request = <String, dynamic>{
|
||||
@@ -890,6 +897,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
'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<LocalAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<void>.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<LocalAlbumScreen> {
|
||||
);
|
||||
|
||||
try {
|
||||
final ok = await _reEnrichLocalTrack(item);
|
||||
final ok = await _reEnrichLocalTrack(item, updateFields: updateFields);
|
||||
if (ok) {
|
||||
successCount++;
|
||||
}
|
||||
|
||||
@@ -329,6 +329,8 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final trackState = ref.read(trackProvider);
|
||||
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
|
||||
+28
-22
@@ -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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4958,7 +4962,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return ffmpegResult != null;
|
||||
}
|
||||
|
||||
Future<bool> _reEnrichQueueLocalTrack(LocalLibraryItem item) async {
|
||||
Future<bool> _reEnrichQueueLocalTrack(
|
||||
LocalLibraryItem item, {
|
||||
List<String>? updateFields,
|
||||
}) async {
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
final request = <String, dynamic>{
|
||||
@@ -4981,6 +4988,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
'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<QueueTab> {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<QueueTab> {
|
||||
);
|
||||
|
||||
try {
|
||||
final ok = await _reEnrichQueueLocalTrack(item);
|
||||
final ok = await _reEnrichQueueLocalTrack(
|
||||
item,
|
||||
updateFields: updateFields,
|
||||
);
|
||||
if (ok) {
|
||||
successCount++;
|
||||
}
|
||||
|
||||
@@ -331,7 +331,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
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,
|
||||
|
||||
@@ -967,6 +967,7 @@ class FFmpegService {
|
||||
required String mp3Path,
|
||||
String? coverPath,
|
||||
Map<String, String>? 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<String, String>? 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 = <String>[
|
||||
'-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<String, String>? 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'
|
||||
|
||||
@@ -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<String> all = [
|
||||
cover,
|
||||
lyrics,
|
||||
basicTags,
|
||||
trackInfo,
|
||||
releaseInfo,
|
||||
extra,
|
||||
];
|
||||
}
|
||||
|
||||
/// Result returned by the re-enrich field selection sheet.
|
||||
class ReEnrichFieldSelection {
|
||||
final List<String> 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<ReEnrichFieldSelection?> showReEnrichFieldDialog(
|
||||
BuildContext context, {
|
||||
required int selectedCount,
|
||||
}) {
|
||||
return showModalBottomSheet<ReEnrichFieldSelection>(
|
||||
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<String> _selected = Set<String>.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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user