From 120ecaa0e5834259f121cf2df6d6db146db7a808 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 30 Mar 2026 12:38:42 +0700 Subject: [PATCH] feat: add artist tag mode setting with split Vorbis support and improve library scan progress - Add artist_tag_mode setting (joined / split_vorbis) for FLAC/Opus multi-artist tags - Split 'Artist A, Artist B' into separate ARTIST= Vorbis comments when split mode is enabled - Join repeated ARTIST/ALBUMARTIST Vorbis comments when reading metadata - Propagate artistTagMode through download pipeline, re-enrich, and metadata editor - Improve library scan progress: separate polling intervals, finalizing state, indeterminate progress - Add initial progress snapshot on library scan stream connect - Use req.ArtistName consistently for Qobuz downloads instead of track.Performer.Name - Add l10n keys for artist tag mode, library files unit, and scan finalizing status --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 19 ++- go_backend/audio_metadata.go | 13 +- go_backend/deezer_download.go | 25 +-- go_backend/exports.go | 103 ++++++------ go_backend/metadata.go | 153 +++++++++++++++--- go_backend/metadata_artist_tags_test.go | 67 ++++++++ go_backend/qobuz.go | 25 +-- go_backend/tidal.go | 25 +-- lib/l10n/app_localizations.dart | 48 ++++++ lib/l10n/app_localizations_de.dart | 35 ++++ lib/l10n/app_localizations_en.dart | 35 ++++ lib/l10n/app_localizations_es.dart | 35 ++++ lib/l10n/app_localizations_fr.dart | 35 ++++ lib/l10n/app_localizations_hi.dart | 35 ++++ lib/l10n/app_localizations_id.dart | 35 ++++ lib/l10n/app_localizations_ja.dart | 35 ++++ lib/l10n/app_localizations_ko.dart | 35 ++++ lib/l10n/app_localizations_nl.dart | 35 ++++ lib/l10n/app_localizations_pt.dart | 35 ++++ lib/l10n/app_localizations_ru.dart | 35 ++++ lib/l10n/app_localizations_tr.dart | 35 ++++ lib/l10n/app_localizations_zh.dart | 35 ++++ lib/l10n/arb/app_en.arb | 37 +++++ lib/models/settings.dart | 6 + lib/models/settings.g.dart | 2 + lib/providers/download_queue_provider.dart | 3 + lib/providers/local_library_provider.dart | 101 +++++++++--- lib/providers/settings_provider.dart | 8 + lib/screens/downloaded_album_screen.dart | 1 + lib/screens/local_album_screen.dart | 5 + lib/screens/queue_tab.dart | 5 + .../settings/library_settings_page.dart | 75 +++++++-- .../settings/options_settings_page.dart | 100 +++++++++++- lib/screens/track_metadata_screen.dart | 11 ++ lib/services/download_request_payload.dart | 4 + lib/services/ffmpeg_service.dart | 116 +++++++++++-- lib/utils/artist_utils.dart | 25 +++ 37 files changed, 1274 insertions(+), 158 deletions(-) create mode 100644 go_backend/metadata_artist_tags_test.go diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 349ab73..8ccd235 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -41,7 +41,8 @@ class MainActivity: FlutterFragmentActivity() { "com.zarz.spotiflac/download_progress_stream" private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream" - private val STREAM_POLLING_INTERVAL_MS = 1200L + private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L + private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 200L private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var pendingSafTreeResult: MethodChannel.Result? = null private val safScanLock = Any() @@ -455,7 +456,7 @@ class MainActivity: FlutterFragmentActivity() { "Download progress stream poll failed: ${e.message}", ) } - delay(STREAM_POLLING_INTERVAL_MS) + delay(DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS) } } } @@ -472,6 +473,18 @@ class MainActivity: FlutterFragmentActivity() { libraryScanProgressEventSink = sink lastLibraryScanProgressPayload = null libraryScanProgressStreamJob = scope.launch { + try { + val initialPayload = withContext(Dispatchers.IO) { + readLibraryScanProgressJsonForStream() + } + lastLibraryScanProgressPayload = initialPayload + sink.success(parseJsonPayload(initialPayload)) + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "Library scan progress initial poll failed: ${e.message}", + ) + } while (isActive && libraryScanProgressEventSink === sink) { try { val payload = withContext(Dispatchers.IO) { @@ -487,7 +500,7 @@ class MainActivity: FlutterFragmentActivity() { "Library scan progress stream poll failed: ${e.message}", ) } - delay(STREAM_POLLING_INTERVAL_MS) + delay(LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS) } } } diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 298ac39..3d8fe65 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -980,6 +980,8 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) { } reader := bytes.NewReader(data) + artistValues := make([]string, 0, 1) + albumArtistValues := make([]string, 0, 1) // Read vendor string length var vendorLen uint32 @@ -1034,9 +1036,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) { case "TITLE": metadata.Title = value case "ARTIST": - metadata.Artist = value + artistValues = append(artistValues, value) case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST": - metadata.AlbumArtist = value + albumArtistValues = append(albumArtistValues, value) case "ALBUM": metadata.Album = value case "DATE", "YEAR": @@ -1066,6 +1068,13 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) { metadata.Copyright = value } } + + if len(artistValues) > 0 { + metadata.Artist = joinVorbisCommentValues(artistValues) + } + if len(albumArtistValues) > 0 { + metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues) + } } func GetOggQuality(filePath string) (*OggQuality, error) { diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index 8e76ee9..cf706e9 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -524,18 +524,19 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) { } metadata := Metadata{ - Title: req.TrackName, - Artist: req.ArtistName, - Album: req.AlbumName, - AlbumArtist: req.AlbumArtist, - Date: req.ReleaseDate, - TrackNumber: req.TrackNumber, - TotalTracks: req.TotalTracks, - DiscNumber: req.DiscNumber, - ISRC: req.ISRC, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, + Title: req.TrackName, + Artist: req.ArtistName, + Album: req.AlbumName, + AlbumArtist: req.AlbumArtist, + ArtistTagMode: req.ArtistTagMode, + Date: req.ReleaseDate, + TrackNumber: req.TrackNumber, + TotalTracks: req.TotalTracks, + DiscNumber: req.DiscNumber, + ISRC: req.ISRC, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, } var coverData []byte diff --git a/go_backend/exports.go b/go_backend/exports.go index 2300b18..e62aa2f 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -49,6 +49,7 @@ type DownloadRequest struct { FilenameFormat string `json:"filename_format"` Quality string `json:"quality"` EmbedMetadata bool `json:"embed_metadata"` + ArtistTagMode string `json:"artist_tag_mode,omitempty"` EmbedLyrics bool `json:"embed_lyrics"` EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` TrackNumber int `json:"track_number"` @@ -117,24 +118,25 @@ 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"` - 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"` } func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) { @@ -191,12 +193,13 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) { func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest { return DownloadRequest{ - TrackName: req.TrackName, - ArtistName: req.ArtistName, - AlbumName: req.AlbumName, - ReleaseDate: req.ReleaseDate, - ISRC: req.ISRC, - DurationMS: int(req.DurationMs), + TrackName: req.TrackName, + ArtistName: req.ArtistName, + AlbumName: req.AlbumName, + ReleaseDate: req.ReleaseDate, + ISRC: req.ISRC, + DurationMS: int(req.DurationMs), + ArtistTagMode: req.ArtistTagMode, } } @@ -1195,19 +1198,20 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { } meta := Metadata{ - Title: fields["title"], - Artist: fields["artist"], - Album: fields["album"], - AlbumArtist: fields["album_artist"], - Date: fields["date"], - TrackNumber: trackNum, - DiscNumber: discNum, - ISRC: fields["isrc"], - Genre: fields["genre"], - Label: fields["label"], - Copyright: fields["copyright"], - Composer: fields["composer"], - Comment: fields["comment"], + Title: fields["title"], + Artist: fields["artist"], + Album: fields["album"], + AlbumArtist: fields["album_artist"], + ArtistTagMode: fields["artist_tag_mode"], + Date: fields["date"], + TrackNumber: trackNum, + DiscNumber: discNum, + ISRC: fields["isrc"], + Genre: fields["genre"], + Label: fields["label"], + Copyright: fields["copyright"], + Composer: fields["composer"], + Comment: fields["comment"], } if err := EmbedMetadata(filePath, meta, coverPath); err != nil { @@ -2210,18 +2214,19 @@ func ReEnrichFile(requestJSON string) (string, error) { if isFlac { // Native Go FLAC metadata embedding metadata := Metadata{ - Title: req.TrackName, - Artist: req.ArtistName, - Album: req.AlbumName, - AlbumArtist: req.AlbumArtist, - Date: req.ReleaseDate, - TrackNumber: req.TrackNumber, - DiscNumber: req.DiscNumber, - ISRC: req.ISRC, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, - Lyrics: lyricsLRC, + 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 len(coverDataBytes) > 0 { diff --git a/go_backend/metadata.go b/go_backend/metadata.go index fdd856e..62b48aa 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -11,6 +11,7 @@ import ( "io" "os" "path/filepath" + "regexp" "strconv" "strings" @@ -19,6 +20,10 @@ import ( "github.com/go-flac/go-flac/v2" ) +const artistTagModeSplitVorbis = "split_vorbis" + +var artistTagSplitPattern = regexp.MustCompile(`\s*(?:,|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?\s*`) + func detectCoverMIME(coverPath string, coverData []byte) string { // Prefer magic-byte detection over file extension. // Some providers return non-JPEG data behind .jpg URLs. @@ -96,22 +101,23 @@ func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, } type Metadata struct { - Title string - Artist string - Album string - AlbumArtist string - Date string - TrackNumber int - TotalTracks int - DiscNumber int - ISRC string - Description string - Lyrics string - Genre string - Label string - Copyright string - Composer string - Comment string + Title string + Artist string + Album string + AlbumArtist string + ArtistTagMode string + Date string + TrackNumber int + TotalTracks int + DiscNumber int + ISRC string + Description string + Lyrics string + Genre string + Label string + Copyright string + Composer string + Comment string } func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { @@ -139,9 +145,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { } setComment(cmt, "TITLE", metadata.Title) - setComment(cmt, "ARTIST", metadata.Artist) + setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode) setComment(cmt, "ALBUM", metadata.Album) - setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist) + setArtistComments( + cmt, + "ALBUMARTIST", + metadata.AlbumArtist, + metadata.ArtistTagMode, + ) setComment(cmt, "DATE", metadata.Date) if metadata.TrackNumber > 0 { @@ -248,9 +259,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData [] } setComment(cmt, "TITLE", metadata.Title) - setComment(cmt, "ARTIST", metadata.Artist) + setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode) setComment(cmt, "ALBUM", metadata.Album) - setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist) + setArtistComments( + cmt, + "ALBUMARTIST", + metadata.AlbumArtist, + metadata.ArtistTagMode, + ) setComment(cmt, "DATE", metadata.Date) if metadata.TrackNumber > 0 { @@ -339,9 +355,9 @@ func ReadMetadata(filePath string) (*Metadata, error) { } metadata.Title = getComment(cmt, "TITLE") - metadata.Artist = getComment(cmt, "ARTIST") + metadata.Artist = getJoinedComment(cmt, "ARTIST") metadata.Album = getComment(cmt, "ALBUM") - metadata.AlbumArtist = getComment(cmt, "ALBUMARTIST") + metadata.AlbumArtist = getJoinedComment(cmt, "ALBUMARTIST") metadata.Date = getComment(cmt, "DATE") metadata.ISRC = getComment(cmt, "ISRC") metadata.Description = getComment(cmt, "DESCRIPTION") @@ -394,6 +410,28 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) { if value == "" { return } + removeCommentKey(cmt, key) + cmt.Comments = append(cmt.Comments, key+"="+value) +} + +func setArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, mode string) { + values := []string{value} + if shouldSplitVorbisArtistTags(mode) { + values = splitArtistTagValues(value) + } + if len(values) == 0 { + return + } + removeCommentKey(cmt, key) + for _, artist := range values { + if strings.TrimSpace(artist) == "" { + continue + } + cmt.Comments = append(cmt.Comments, key+"="+artist) + } +} + +func removeCommentKey(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) { keyUpper := strings.ToUpper(key) for i := len(cmt.Comments) - 1; i >= 0; i-- { comment := cmt.Comments[i] @@ -405,20 +443,85 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) { } } } - cmt.Comments = append(cmt.Comments, key+"="+value) } func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string { + values := getCommentValues(cmt, key) + if len(values) == 0 { + return "" + } + return values[0] +} + +func getJoinedComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string { + return joinVorbisCommentValues(getCommentValues(cmt, key)) +} + +func getCommentValues(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) []string { keyUpper := strings.ToUpper(key) + "=" + values := make([]string, 0, 1) for _, comment := range cmt.Comments { if len(comment) > len(key) { commentUpper := strings.ToUpper(comment[:len(key)+1]) if commentUpper == keyUpper { - return comment[len(key)+1:] + values = append(values, comment[len(key)+1:]) } } } - return "" + return values +} + +func shouldSplitVorbisArtistTags(mode string) bool { + return strings.EqualFold(strings.TrimSpace(mode), artistTagModeSplitVorbis) +} + +func splitArtistTagValues(rawArtists string) []string { + trimmed := strings.TrimSpace(rawArtists) + if trimmed == "" { + return nil + } + + parts := artistTagSplitPattern.Split(trimmed, -1) + values := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + artist := strings.TrimSpace(part) + if artist == "" { + continue + } + key := strings.ToLower(artist) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + values = append(values, artist) + } + if len(values) > 0 { + return values + } + return []string{trimmed} +} + +func joinVorbisCommentValues(values []string) string { + if len(values) == 0 { + return "" + } + + joined := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + joined = append(joined, trimmed) + } + return strings.Join(joined, ", ") } func fileExists(path string) bool { diff --git a/go_backend/metadata_artist_tags_test.go b/go_backend/metadata_artist_tags_test.go new file mode 100644 index 0000000..32ea544 --- /dev/null +++ b/go_backend/metadata_artist_tags_test.go @@ -0,0 +1,67 @@ +package gobackend + +import ( + "bytes" + "encoding/binary" + "slices" + "testing" + + "github.com/go-flac/flacvorbis/v2" +) + +func TestSplitArtistTagValues(t *testing.T) { + got := splitArtistTagValues("Artist A, Artist B feat. Artist C & Artist B") + want := []string{"Artist A", "Artist B", "Artist C"} + if !slices.Equal(got, want) { + t.Fatalf("splitArtistTagValues() = %#v, want %#v", got, want) + } +} + +func TestSetArtistCommentsSplitVorbis(t *testing.T) { + cmt := flacvorbis.New() + setArtistComments(cmt, "ARTIST", "Artist A, Artist B", artistTagModeSplitVorbis) + + got := getCommentValues(cmt, "ARTIST") + want := []string{"Artist A", "Artist B"} + if !slices.Equal(got, want) { + t.Fatalf("getCommentValues(ARTIST) = %#v, want %#v", got, want) + } +} + +func TestParseVorbisCommentsJoinsRepeatedArtists(t *testing.T) { + metadata := &AudioMetadata{} + parseVorbisComments( + buildVorbisCommentPayload( + []string{ + "TITLE=Song", + "ARTIST=Artist A", + "ARTIST=Artist B", + "ALBUMARTIST=Album Artist A", + "ALBUMARTIST=Album Artist B", + }, + ), + metadata, + ) + + if metadata.Title != "Song" { + t.Fatalf("title = %q", metadata.Title) + } + if metadata.Artist != "Artist A, Artist B" { + t.Fatalf("artist = %q", metadata.Artist) + } + if metadata.AlbumArtist != "Album Artist A, Album Artist B" { + t.Fatalf("album artist = %q", metadata.AlbumArtist) + } +} + +func buildVorbisCommentPayload(comments []string) []byte { + var buf bytes.Buffer + _ = binary.Write(&buf, binary.LittleEndian, uint32(len("spotiflac"))) + buf.WriteString("spotiflac") + _ = binary.Write(&buf, binary.LittleEndian, uint32(len(comments))) + for _, comment := range comments { + _ = binary.Write(&buf, binary.LittleEndian, uint32(len(comment))) + buf.WriteString(comment) + } + return buf.Bytes() +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index ebf8539..ca1c1f6 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -2585,18 +2585,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { } metadata := Metadata{ - Title: track.Title, - Artist: track.Performer.Name, - Album: albumName, - AlbumArtist: req.AlbumArtist, - Date: releaseDate, - TrackNumber: actualTrackNumber, - TotalTracks: req.TotalTracks, - DiscNumber: req.DiscNumber, - ISRC: track.ISRC, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, + Title: track.Title, + Artist: req.ArtistName, + Album: albumName, + AlbumArtist: req.AlbumArtist, + ArtistTagMode: req.ArtistTagMode, + Date: releaseDate, + TrackNumber: actualTrackNumber, + TotalTracks: req.TotalTracks, + DiscNumber: req.DiscNumber, + ISRC: track.ISRC, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, } var coverData []byte diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 83848dc..de36069 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -2354,18 +2354,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } metadata := Metadata{ - Title: req.TrackName, - Artist: req.ArtistName, - Album: req.AlbumName, - AlbumArtist: req.AlbumArtist, - Date: releaseDate, - TrackNumber: actualTrackNumber, - TotalTracks: req.TotalTracks, - DiscNumber: actualDiscNumber, - ISRC: track.ISRC, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, + Title: req.TrackName, + Artist: req.ArtistName, + Album: req.AlbumName, + AlbumArtist: req.AlbumArtist, + ArtistTagMode: req.ArtistTagMode, + Date: releaseDate, + TrackNumber: actualTrackNumber, + TotalTracks: req.TotalTracks, + DiscNumber: actualDiscNumber, + ISRC: track.ISRC, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, } var coverData []byte diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a5f9550..eb925b9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -400,6 +400,42 @@ abstract class AppLocalizations { /// **'Download highest resolution cover art'** String get optionsMaxQualityCoverSubtitle; + /// Setting title for how artist metadata is written into files + /// + /// In en, this message translates to: + /// **'Artist Tag Mode'** + String get optionsArtistTagMode; + + /// Bottom-sheet description for artist tag mode setting + /// + /// In en, this message translates to: + /// **'Choose how multiple artists are written into embedded tags.'** + String get optionsArtistTagModeDescription; + + /// Artist tag mode option that joins multiple artists into one value + /// + /// In en, this message translates to: + /// **'Single joined value'** + String get optionsArtistTagModeJoined; + + /// Subtitle for joined artist tag mode + /// + /// In en, this message translates to: + /// **'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'** + String get optionsArtistTagModeJoinedSubtitle; + + /// Artist tag mode option that writes repeated ARTIST tags for Vorbis formats + /// + /// In en, this message translates to: + /// **'Split tags for FLAC/Opus'** + String get optionsArtistTagModeSplitVorbis; + + /// Subtitle for split Vorbis artist tag mode + /// + /// In en, this message translates to: + /// **'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'** + String get optionsArtistTagModeSplitVorbisSubtitle; + /// Number of parallel downloads /// /// In en, this message translates to: @@ -3334,6 +3370,12 @@ abstract class AppLocalizations { /// **'{count, plural, =1{track} other{tracks}}'** String libraryTracksUnit(int count); + /// Unit label for files count during library scanning + /// + /// In en, this message translates to: + /// **'{count, plural, =1{file} other{files}}'** + String libraryFilesUnit(int count); + /// Last scan time display /// /// In en, this message translates to: @@ -3352,6 +3394,12 @@ abstract class AppLocalizations { /// **'Scanning...'** String get libraryScanning; + /// Status shown after file scanning finishes but library persistence is still running + /// + /// In en, this message translates to: + /// **'Finalizing library...'** + String get libraryScanFinalizing; + /// Scan progress display /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a94b703..6235383 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -158,6 +158,27 @@ class AppLocalizationsDe extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Cover in höchster Auflösung herunterladen'; + @override + String get optionsArtistTagMode => 'Artist Tag Mode'; + + @override + String get optionsArtistTagModeDescription => + 'Choose how multiple artists are written into embedded tags.'; + + @override + String get optionsArtistTagModeJoined => 'Single joined value'; + + @override + String get optionsArtistTagModeJoinedSubtitle => + 'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'; + + @override + String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus'; + + @override + String get optionsArtistTagModeSplitVorbisSubtitle => + 'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'; + @override String get optionsConcurrentDownloads => 'Parallele Downloads'; @@ -1851,6 +1872,17 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String libraryFilesUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'files', + one: 'file', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Zuletzt gescannt: $time'; @@ -1862,6 +1894,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get libraryScanning => 'Scannen...'; + @override + String get libraryScanFinalizing => 'Finalizing library...'; + @override String libraryScanProgress(String progress, int total) { return '$progress% von $total Dateien'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a7c4b46..635a701 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -154,6 +154,27 @@ class AppLocalizationsEn extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsArtistTagMode => 'Artist Tag Mode'; + + @override + String get optionsArtistTagModeDescription => + 'Choose how multiple artists are written into embedded tags.'; + + @override + String get optionsArtistTagModeJoined => 'Single joined value'; + + @override + String get optionsArtistTagModeJoinedSubtitle => + 'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'; + + @override + String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus'; + + @override + String get optionsArtistTagModeSplitVorbisSubtitle => + 'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'; + @override String get optionsConcurrentDownloads => 'Concurrent Downloads'; @@ -1823,6 +1844,17 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } + @override + String libraryFilesUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'files', + one: 'file', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -1834,6 +1866,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get libraryScanning => 'Scanning...'; + @override + String get libraryScanFinalizing => 'Finalizing library...'; + @override String libraryScanProgress(String progress, int total) { return '$progress% of $total files'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index e394b14..79be5bb 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -154,6 +154,27 @@ class AppLocalizationsEs extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsArtistTagMode => 'Artist Tag Mode'; + + @override + String get optionsArtistTagModeDescription => + 'Choose how multiple artists are written into embedded tags.'; + + @override + String get optionsArtistTagModeJoined => 'Single joined value'; + + @override + String get optionsArtistTagModeJoinedSubtitle => + 'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'; + + @override + String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus'; + + @override + String get optionsArtistTagModeSplitVorbisSubtitle => + 'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'; + @override String get optionsConcurrentDownloads => 'Concurrent Downloads'; @@ -1823,6 +1844,17 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String libraryFilesUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'files', + one: 'file', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -1834,6 +1866,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get libraryScanning => 'Scanning...'; + @override + String get libraryScanFinalizing => 'Finalizing library...'; + @override String libraryScanProgress(String progress, int total) { return '$progress% of $total files'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index fa2884d..32d73a1 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -156,6 +156,27 @@ class AppLocalizationsFr extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsArtistTagMode => 'Artist Tag Mode'; + + @override + String get optionsArtistTagModeDescription => + 'Choose how multiple artists are written into embedded tags.'; + + @override + String get optionsArtistTagModeJoined => 'Single joined value'; + + @override + String get optionsArtistTagModeJoinedSubtitle => + 'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'; + + @override + String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus'; + + @override + String get optionsArtistTagModeSplitVorbisSubtitle => + 'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'; + @override String get optionsConcurrentDownloads => 'Concurrent Downloads'; @@ -1825,6 +1846,17 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String libraryFilesUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'files', + one: 'file', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -1836,6 +1868,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get libraryScanning => 'Scanning...'; + @override + String get libraryScanFinalizing => 'Finalizing library...'; + @override String libraryScanProgress(String progress, int total) { return '$progress% of $total files'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 914e06c..b5ba989 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -154,6 +154,27 @@ class AppLocalizationsHi extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsArtistTagMode => 'Artist Tag Mode'; + + @override + String get optionsArtistTagModeDescription => + 'Choose how multiple artists are written into embedded tags.'; + + @override + String get optionsArtistTagModeJoined => 'Single joined value'; + + @override + String get optionsArtistTagModeJoinedSubtitle => + 'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'; + + @override + String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus'; + + @override + String get optionsArtistTagModeSplitVorbisSubtitle => + 'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'; + @override String get optionsConcurrentDownloads => 'Concurrent Downloads'; @@ -1823,6 +1844,17 @@ class AppLocalizationsHi extends AppLocalizations { return '$_temp0'; } + @override + String libraryFilesUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'files', + one: 'file', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -1834,6 +1866,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get libraryScanning => 'Scanning...'; + @override + String get libraryScanFinalizing => 'Finalizing library...'; + @override String libraryScanProgress(String progress, int total) { return '$progress% of $total files'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index e2ae27f..1741bf3 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -158,6 +158,27 @@ class AppLocalizationsId extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Unduh cover art resolusi tertinggi'; + @override + String get optionsArtistTagMode => 'Artist Tag Mode'; + + @override + String get optionsArtistTagModeDescription => + 'Choose how multiple artists are written into embedded tags.'; + + @override + String get optionsArtistTagModeJoined => 'Single joined value'; + + @override + String get optionsArtistTagModeJoinedSubtitle => + 'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'; + + @override + String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus'; + + @override + String get optionsArtistTagModeSplitVorbisSubtitle => + 'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'; + @override String get optionsConcurrentDownloads => 'Unduhan Bersamaan'; @@ -1833,6 +1854,17 @@ class AppLocalizationsId extends AppLocalizations { return '$_temp0'; } + @override + String libraryFilesUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'files', + one: 'file', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -1844,6 +1876,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get libraryScanning => 'Scanning...'; + @override + String get libraryScanFinalizing => 'Finalizing library...'; + @override String libraryScanProgress(String progress, int total) { return '$progress% of $total files'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index bcb8820..9bfac41 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -152,6 +152,27 @@ class AppLocalizationsJa extends AppLocalizations { @override String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード'; + @override + String get optionsArtistTagMode => 'Artist Tag Mode'; + + @override + String get optionsArtistTagModeDescription => + 'Choose how multiple artists are written into embedded tags.'; + + @override + String get optionsArtistTagModeJoined => 'Single joined value'; + + @override + String get optionsArtistTagModeJoinedSubtitle => + 'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'; + + @override + String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus'; + + @override + String get optionsArtistTagModeSplitVorbisSubtitle => + 'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'; + @override String get optionsConcurrentDownloads => '同時ダウンロード'; @@ -1810,6 +1831,17 @@ class AppLocalizationsJa extends AppLocalizations { return '$_temp0'; } + @override + String libraryFilesUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'files', + one: 'file', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return '最終スキャン: $time'; @@ -1821,6 +1853,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get libraryScanning => 'スキャン中...'; + @override + String get libraryScanFinalizing => 'Finalizing library...'; + @override String libraryScanProgress(String progress, int total) { return '$progress% of $total files'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 4da2181..f697810 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -148,6 +148,27 @@ class AppLocalizationsKo extends AppLocalizations { @override String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드'; + @override + String get optionsArtistTagMode => 'Artist Tag Mode'; + + @override + String get optionsArtistTagModeDescription => + 'Choose how multiple artists are written into embedded tags.'; + + @override + String get optionsArtistTagModeJoined => 'Single joined value'; + + @override + String get optionsArtistTagModeJoinedSubtitle => + 'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'; + + @override + String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus'; + + @override + String get optionsArtistTagModeSplitVorbisSubtitle => + 'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'; + @override String get optionsConcurrentDownloads => '동시 다운로드'; @@ -1803,6 +1824,17 @@ class AppLocalizationsKo extends AppLocalizations { return '$_temp0'; } + @override + String libraryFilesUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'files', + one: 'file', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -1814,6 +1846,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get libraryScanning => 'Scanning...'; + @override + String get libraryScanFinalizing => 'Finalizing library...'; + @override String libraryScanProgress(String progress, int total) { return '$progress% of $total files'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 62eb07b..ac4dcb8 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -154,6 +154,27 @@ class AppLocalizationsNl extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsArtistTagMode => 'Artist Tag Mode'; + + @override + String get optionsArtistTagModeDescription => + 'Choose how multiple artists are written into embedded tags.'; + + @override + String get optionsArtistTagModeJoined => 'Single joined value'; + + @override + String get optionsArtistTagModeJoinedSubtitle => + 'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'; + + @override + String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus'; + + @override + String get optionsArtistTagModeSplitVorbisSubtitle => + 'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'; + @override String get optionsConcurrentDownloads => 'Concurrent Downloads'; @@ -1823,6 +1844,17 @@ class AppLocalizationsNl extends AppLocalizations { return '$_temp0'; } + @override + String libraryFilesUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'files', + one: 'file', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -1834,6 +1866,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get libraryScanning => 'Scanning...'; + @override + String get libraryScanFinalizing => 'Finalizing library...'; + @override String libraryScanProgress(String progress, int total) { return '$progress% of $total files'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index b3e302d..4fd1594 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -154,6 +154,27 @@ class AppLocalizationsPt extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsArtistTagMode => 'Artist Tag Mode'; + + @override + String get optionsArtistTagModeDescription => + 'Choose how multiple artists are written into embedded tags.'; + + @override + String get optionsArtistTagModeJoined => 'Single joined value'; + + @override + String get optionsArtistTagModeJoinedSubtitle => + 'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'; + + @override + String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus'; + + @override + String get optionsArtistTagModeSplitVorbisSubtitle => + 'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'; + @override String get optionsConcurrentDownloads => 'Concurrent Downloads'; @@ -1823,6 +1844,17 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0'; } + @override + String libraryFilesUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'files', + one: 'file', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -1834,6 +1866,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get libraryScanning => 'Scanning...'; + @override + String get libraryScanFinalizing => 'Finalizing library...'; + @override String libraryScanProgress(String progress, int total) { return '$progress% of $total files'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d19fce4..28d0d0d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -159,6 +159,27 @@ class AppLocalizationsRu extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Скачивать обложку в макс. разрешении'; + @override + String get optionsArtistTagMode => 'Artist Tag Mode'; + + @override + String get optionsArtistTagModeDescription => + 'Choose how multiple artists are written into embedded tags.'; + + @override + String get optionsArtistTagModeJoined => 'Single joined value'; + + @override + String get optionsArtistTagModeJoinedSubtitle => + 'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'; + + @override + String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus'; + + @override + String get optionsArtistTagModeSplitVorbisSubtitle => + 'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'; + @override String get optionsConcurrentDownloads => 'Одновременные загрузки'; @@ -1861,6 +1882,17 @@ class AppLocalizationsRu extends AppLocalizations { return '$_temp0'; } + @override + String libraryFilesUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'files', + one: 'file', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Последнее сканирование: $time'; @@ -1872,6 +1904,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get libraryScanning => 'Сканирование...'; + @override + String get libraryScanFinalizing => 'Finalizing library...'; + @override String libraryScanProgress(String progress, int total) { return '$progress% из $total файлов'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 41b44b0..f5c8c41 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -157,6 +157,27 @@ class AppLocalizationsTr extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'En yüksek kalitedeki albüm kapaklarını indir'; + @override + String get optionsArtistTagMode => 'Artist Tag Mode'; + + @override + String get optionsArtistTagModeDescription => + 'Choose how multiple artists are written into embedded tags.'; + + @override + String get optionsArtistTagModeJoined => 'Single joined value'; + + @override + String get optionsArtistTagModeJoinedSubtitle => + 'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'; + + @override + String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus'; + + @override + String get optionsArtistTagModeSplitVorbisSubtitle => + 'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'; + @override String get optionsConcurrentDownloads => 'Eş Zamanlı İndirmeler'; @@ -1829,6 +1850,17 @@ class AppLocalizationsTr extends AppLocalizations { return '$_temp0'; } + @override + String libraryFilesUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'files', + one: 'file', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -1840,6 +1872,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get libraryScanning => 'Scanning...'; + @override + String get libraryScanFinalizing => 'Finalizing library...'; + @override String libraryScanProgress(String progress, int total) { return '$progress% of $total files'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index c4cb90b..ebe8c62 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -154,6 +154,27 @@ class AppLocalizationsZh extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsArtistTagMode => 'Artist Tag Mode'; + + @override + String get optionsArtistTagModeDescription => + 'Choose how multiple artists are written into embedded tags.'; + + @override + String get optionsArtistTagModeJoined => 'Single joined value'; + + @override + String get optionsArtistTagModeJoinedSubtitle => + 'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'; + + @override + String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus'; + + @override + String get optionsArtistTagModeSplitVorbisSubtitle => + 'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'; + @override String get optionsConcurrentDownloads => 'Concurrent Downloads'; @@ -1823,6 +1844,17 @@ class AppLocalizationsZh extends AppLocalizations { return '$_temp0'; } + @override + String libraryFilesUnit(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'files', + one: 'file', + ); + return '$_temp0'; + } + @override String libraryLastScanned(String time) { return 'Last scanned: $time'; @@ -1834,6 +1866,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get libraryScanning => 'Scanning...'; + @override + String get libraryScanFinalizing => 'Finalizing library...'; + @override String libraryScanProgress(String progress, int total) { return '$progress% of $total files'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 26f3844..d03dd3d 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -190,6 +190,30 @@ "@optionsMaxQualityCoverSubtitle": { "description": "Subtitle for max quality cover" }, + "optionsArtistTagMode": "Artist Tag Mode", + "@optionsArtistTagMode": { + "description": "Setting title for how artist metadata is written into files" + }, + "optionsArtistTagModeDescription": "Choose how multiple artists are written into embedded tags.", + "@optionsArtistTagModeDescription": { + "description": "Bottom-sheet description for artist tag mode setting" + }, + "optionsArtistTagModeJoined": "Single joined value", + "@optionsArtistTagModeJoined": { + "description": "Artist tag mode option that joins multiple artists into one value" + }, + "optionsArtistTagModeJoinedSubtitle": "Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.", + "@optionsArtistTagModeJoinedSubtitle": { + "description": "Subtitle for joined artist tag mode" + }, + "optionsArtistTagModeSplitVorbis": "Split tags for FLAC/Opus", + "@optionsArtistTagModeSplitVorbis": { + "description": "Artist tag mode option that writes repeated ARTIST tags for Vorbis formats" + }, + "optionsArtistTagModeSplitVorbisSubtitle": "Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.", + "@optionsArtistTagModeSplitVorbisSubtitle": { + "description": "Subtitle for split Vorbis artist tag mode" + }, "optionsConcurrentDownloads": "Concurrent Downloads", "@optionsConcurrentDownloads": { "description": "Number of parallel downloads" @@ -2399,6 +2423,15 @@ } } }, + "libraryFilesUnit": "{count, plural, =1{file} other{files}}", + "@libraryFilesUnit": { + "description": "Unit label for files count during library scanning", + "placeholders": { + "count": { + "type": "int" + } + } + }, "libraryLastScanned": "Last scanned: {time}", "@libraryLastScanned": { "description": "Last scan time display", @@ -2416,6 +2449,10 @@ "@libraryScanning": { "description": "Status during scan" }, + "libraryScanFinalizing": "Finalizing library...", + "@libraryScanFinalizing": { + "description": "Status shown after file scanning finishes but library persistence is still running" + }, "libraryScanProgress": "{progress}% of {total} files", "@libraryScanProgress": { "description": "Scan progress display", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 63f87ab..4d712b1 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; part 'settings.g.dart'; @@ -12,6 +13,8 @@ class AppSettings { final String downloadTreeUri; // SAF persistable tree URI final bool autoFallback; final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding + final String + artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats final bool embedLyrics; final bool maxQualityCover; final bool isFirstLaunch; @@ -88,6 +91,7 @@ class AppSettings { this.downloadTreeUri = '', this.autoFallback = true, this.embedMetadata = true, + this.artistTagMode = artistTagModeJoined, this.embedLyrics = true, this.maxQualityCover = true, this.isFirstLaunch = true, @@ -152,6 +156,7 @@ class AppSettings { String? downloadTreeUri, bool? autoFallback, bool? embedMetadata, + String? artistTagMode, bool? embedLyrics, bool? maxQualityCover, bool? isFirstLaunch, @@ -210,6 +215,7 @@ class AppSettings { downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, autoFallback: autoFallback ?? this.autoFallback, embedMetadata: embedMetadata ?? this.embedMetadata, + artistTagMode: artistTagMode ?? this.artistTagMode, embedLyrics: embedLyrics ?? this.embedLyrics, maxQualityCover: maxQualityCover ?? this.maxQualityCover, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index d78bc81..c3b39fd 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -15,6 +15,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( downloadTreeUri: json['downloadTreeUri'] as String? ?? '', autoFallback: json['autoFallback'] as bool? ?? true, embedMetadata: json['embedMetadata'] as bool? ?? true, + artistTagMode: json['artistTagMode'] as String? ?? 'joined', embedLyrics: json['embedLyrics'] as bool? ?? true, maxQualityCover: json['maxQualityCover'] as bool? ?? true, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, @@ -93,6 +94,7 @@ Map _$AppSettingsToJson( 'downloadTreeUri': instance.downloadTreeUri, 'autoFallback': instance.autoFallback, 'embedMetadata': instance.embedMetadata, + 'artistTagMode': instance.artistTagMode, 'embedLyrics': instance.embedLyrics, 'maxQualityCover': instance.maxQualityCover, 'isFirstLaunch': instance.isFirstLaunch, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index df778d3..1480cf7 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2996,6 +2996,7 @@ class DownloadQueueNotifier extends Notifier { ? coverPath : null, metadata: metadata, + artistTagMode: settings.artistTagMode, ); if (result != null) { @@ -3328,6 +3329,7 @@ class DownloadQueueNotifier extends Notifier { ? coverPath : null, metadata: metadata, + artistTagMode: settings.artistTagMode, ); if (result != null) { @@ -4215,6 +4217,7 @@ class DownloadQueueNotifier extends Notifier { filenameFormat: state.filenameFormat, quality: quality, embedMetadata: metadataEmbeddingEnabled, + artistTagMode: settings.artistTagMode, embedLyrics: metadataEmbeddingEnabled && settings.embedLyrics, embedMaxQualityCover: metadataEmbeddingEnabled && settings.maxQualityCover, diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 5e35b76..8328f10 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -20,6 +20,7 @@ final _prefs = SharedPreferences.getInstance(); class LocalLibraryState { final List items; final bool isScanning; + final bool scanIsFinalizing; final double scanProgress; final String? scanCurrentFile; final int scanTotalFiles; @@ -35,6 +36,7 @@ class LocalLibraryState { LocalLibraryState({ this.items = const [], this.isScanning = false, + this.scanIsFinalizing = false, this.scanProgress = 0, this.scanCurrentFile, this.scanTotalFiles = 0, @@ -85,6 +87,7 @@ class LocalLibraryState { LocalLibraryState copyWith({ List? items, bool? isScanning, + bool? scanIsFinalizing, double? scanProgress, String? scanCurrentFile, int? scanTotalFiles, @@ -100,6 +103,7 @@ class LocalLibraryState { return LocalLibraryState( items: nextItems, isScanning: isScanning ?? this.isScanning, + scanIsFinalizing: scanIsFinalizing ?? this.scanIsFinalizing, scanProgress: scanProgress ?? this.scanProgress, scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile, scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles, @@ -120,7 +124,8 @@ class LocalLibraryNotifier extends Notifier { final LibraryDatabase _db = LibraryDatabase.instance; final HistoryDatabase _historyDb = HistoryDatabase.instance; final NotificationService _notificationService = NotificationService(); - static const _progressPollingInterval = Duration(milliseconds: 1200); + static const _progressPollingInterval = Duration(milliseconds: 350); + static const _progressStreamBootstrapTimeout = Duration(milliseconds: 900); Timer? _progressTimer; Timer? _progressStreamBootstrapTimer; StreamSubscription>? _progressStreamSub; @@ -220,6 +225,7 @@ class LocalLibraryNotifier extends Notifier { ); state = state.copyWith( isScanning: true, + scanIsFinalizing: false, scanProgress: 0, scanCurrentFile: null, scanTotalFiles: 0, @@ -297,11 +303,21 @@ class LocalLibraryNotifier extends Notifier { ? await PlatformBridge.scanSafTree(effectiveFolderPath) : await PlatformBridge.scanLibraryFolder(effectiveFolderPath); if (_scanCancelRequested) { - state = state.copyWith(isScanning: false, scanWasCancelled: true); + state = state.copyWith( + isScanning: false, + scanIsFinalizing: false, + scanWasCancelled: true, + ); await _showScanCancelledNotification(); return; } + state = state.copyWith( + scanIsFinalizing: true, + scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99, + scanCurrentFile: null, + ); + final items = []; int skippedDownloads = 0; for (final json in results) { @@ -334,6 +350,7 @@ class LocalLibraryNotifier extends Notifier { state = state.copyWith( items: persistedItems, isScanning: false, + scanIsFinalizing: false, scanProgress: 100, lastScannedAt: now, scanWasCancelled: false, @@ -404,11 +421,21 @@ class LocalLibraryNotifier extends Notifier { } if (_scanCancelRequested) { - state = state.copyWith(isScanning: false, scanWasCancelled: true); + state = state.copyWith( + isScanning: false, + scanIsFinalizing: false, + scanWasCancelled: true, + ); await _showScanCancelledNotification(); return; } + state = state.copyWith( + scanIsFinalizing: true, + scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99, + scanCurrentFile: null, + ); + final scannedList = (result['files'] as List?) ?? (result['scanned'] as List?) ?? @@ -498,6 +525,7 @@ class LocalLibraryNotifier extends Notifier { state = state.copyWith( items: items, isScanning: false, + scanIsFinalizing: false, scanProgress: 100, lastScannedAt: now, scanWasCancelled: false, @@ -517,7 +545,11 @@ class LocalLibraryNotifier extends Notifier { } } catch (e, stack) { _log.e('Library scan failed: $e', e, stack); - state = state.copyWith(isScanning: false, scanWasCancelled: false); + state = state.copyWith( + isScanning: false, + scanIsFinalizing: false, + scanWasCancelled: false, + ); await _showScanFailedNotification(e.toString()); } finally { if (didStartSecurityAccess) { @@ -574,16 +606,21 @@ class LocalLibraryNotifier extends Notifier { cancelOnError: false, ); - _progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () { - if (_hasReceivedProgressStreamEvent) { - return; - } - _log.w('Library scan progress stream timeout, fallback to polling'); - _progressStreamSub?.cancel(); - _progressStreamSub = null; - _usingProgressStream = false; - _startProgressPollingTimer(); - }); + Future.microtask(_requestProgressSnapshot); + + _progressStreamBootstrapTimer = Timer( + _progressStreamBootstrapTimeout, + () { + if (_hasReceivedProgressStreamEvent) { + return; + } + _log.w('Library scan progress stream timeout, fallback to polling'); + _progressStreamSub?.cancel(); + _progressStreamSub = null; + _usingProgressStream = false; + _startProgressPollingTimer(); + }, + ); return; } @@ -610,20 +647,41 @@ class LocalLibraryNotifier extends Notifier { }); } + Future _requestProgressSnapshot() async { + if (_isProgressPollingInFlight) return; + _isProgressPollingInFlight = true; + try { + final progress = await PlatformBridge.getLibraryScanProgress(); + await _handleLibraryScanProgress(progress); + _progressPollingErrorCount = 0; + } catch (e) { + _progressPollingErrorCount++; + if (_progressPollingErrorCount <= 3) { + _log.w('Initial library scan progress fetch failed: $e'); + } + } finally { + _isProgressPollingInFlight = false; + } + } + Future _handleLibraryScanProgress(Map progress) async { final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0; final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( 0.0, 100.0, ); + final isComplete = progress['is_complete'] == true; + final displayProgress = isComplete + ? 99.0 + : (normalizedProgress >= 100.0 ? 99.0 : normalizedProgress); final currentFile = progress['current_file'] as String?; final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0; final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0; final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0; - final isComplete = progress['is_complete'] == true; final shouldUpdateState = - state.scanProgress != normalizedProgress || + state.scanProgress != displayProgress || + state.scanIsFinalizing != isComplete || state.scanCurrentFile != currentFile || state.scanTotalFiles != totalFiles || state.scannedFiles != scannedFiles || @@ -631,8 +689,9 @@ class LocalLibraryNotifier extends Notifier { if (shouldUpdateState) { state = state.copyWith( - scanProgress: normalizedProgress, - scanCurrentFile: currentFile, + scanIsFinalizing: isComplete, + scanProgress: displayProgress, + scanCurrentFile: isComplete ? null : currentFile, scanTotalFiles: totalFiles, scannedFiles: scannedFiles, scanErrorCount: errorCount, @@ -705,7 +764,11 @@ class LocalLibraryNotifier extends Notifier { _log.i('Cancelling library scan'); _scanCancelRequested = true; await PlatformBridge.cancelLibraryScan(); - state = state.copyWith(isScanning: false, scanWasCancelled: true); + state = state.copyWith( + isScanning: false, + scanIsFinalizing: false, + scanWasCancelled: true, + ); _stopProgressPolling(); await _showScanCancelledNotification(); } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 832ecff..cc0854c 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -6,6 +6,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -260,6 +261,13 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setArtistTagMode(String mode) { + if (mode == artistTagModeJoined || mode == artistTagModeSplitVorbis) { + state = state.copyWith(artistTagMode: mode); + _saveSettings(); + } + } + void setLyricsMode(String mode) { if (mode == 'embed' || mode == 'external' || mode == 'both') { state = state.copyWith(lyricsMode: mode); diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index c5ee5d1..adcbca1 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1235,6 +1235,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { bitrate: bitrate, metadata: metadata, coverPath: coverPath, + artistTagMode: settings.artistTagMode, deleteOriginal: !isSaf, ); diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index e7e3f41..3ad77b6 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -817,6 +817,7 @@ class _LocalAlbumScreenState extends ConsumerState { lowerPath.endsWith('.opus') || lowerPath.endsWith('.ogg'); + final artistTagMode = ref.read(settingsProvider).artistTagMode; String? ffmpegResult; if (isMp3) { ffmpegResult = await FFmpegService.embedMetadataToMp3( @@ -835,6 +836,7 @@ class _LocalAlbumScreenState extends ConsumerState { opusPath: ffmpegTarget, coverPath: effectiveCoverPath, metadata: metadata, + artistTagMode: artistTagMode, ); } @@ -867,11 +869,13 @@ class _LocalAlbumScreenState extends ConsumerState { Future _reEnrichLocalTrack(LocalLibraryItem item) async { final durationMs = (item.duration ?? 0) * 1000; + final artistTagMode = ref.read(settingsProvider).artistTagMode; final request = { 'file_path': item.filePath, 'cover_url': '', 'max_quality': true, 'embed_lyrics': true, + 'artist_tag_mode': artistTagMode, 'spotify_id': '', 'track_name': item.trackName, 'artist_name': item.artistName, @@ -1510,6 +1514,7 @@ class _LocalAlbumScreenState extends ConsumerState { bitrate: bitrate, metadata: metadata, coverPath: coverPath, + artistTagMode: settings.artistTagMode, deleteOriginal: !isSaf, ); diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index a898fb0..f604ec1 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4906,6 +4906,7 @@ class _QueueTabState extends ConsumerState { lowerPath.endsWith('.opus') || lowerPath.endsWith('.ogg'); + final artistTagMode = ref.read(settingsProvider).artistTagMode; String? ffmpegResult; if (isMp3) { ffmpegResult = await FFmpegService.embedMetadataToMp3( @@ -4924,6 +4925,7 @@ class _QueueTabState extends ConsumerState { opusPath: ffmpegTarget, coverPath: effectiveCoverPath, metadata: metadata, + artistTagMode: artistTagMode, ); } @@ -4958,11 +4960,13 @@ class _QueueTabState extends ConsumerState { Future _reEnrichQueueLocalTrack(LocalLibraryItem item) async { final durationMs = (item.duration ?? 0) * 1000; + final artistTagMode = ref.read(settingsProvider).artistTagMode; final request = { 'file_path': item.filePath, 'cover_url': '', 'max_quality': true, 'embed_lyrics': true, + 'artist_tag_mode': artistTagMode, 'spotify_id': '', 'track_name': item.trackName, 'artist_name': item.artistName, @@ -5663,6 +5667,7 @@ class _QueueTabState extends ConsumerState { bitrate: bitrate, metadata: metadata, coverPath: coverPath, + artistTagMode: settings.artistTagMode, deleteOriginal: !isSaf, ); diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 83014ec..bd47498 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -392,6 +392,7 @@ class _LibrarySettingsPageState extends ConsumerState { itemCount: libraryState.items.length, excludedDownloadedCount: libraryState.excludedDownloadedCount, isScanning: libraryState.isScanning, + scanIsFinalizing: libraryState.scanIsFinalizing, scanProgress: libraryState.scanProgress, scanCurrentFile: libraryState.scanCurrentFile, scanTotalFiles: libraryState.scanTotalFiles, @@ -528,8 +529,10 @@ class _LibrarySettingsPageState extends ConsumerState { children: [ if (libraryState.isScanning) _ScanProgressTile( + isFinalizing: libraryState.scanIsFinalizing, progress: libraryState.scanProgress, currentFile: libraryState.scanCurrentFile, + scannedFiles: libraryState.scannedFiles, totalFiles: libraryState.scanTotalFiles, onCancel: _cancelScan, ) @@ -646,6 +649,7 @@ class _LibraryHeroCard extends StatelessWidget { final int itemCount; final int excludedDownloadedCount; final bool isScanning; + final bool scanIsFinalizing; final double scanProgress; final String? scanCurrentFile; final int scanTotalFiles; @@ -656,6 +660,7 @@ class _LibraryHeroCard extends StatelessWidget { required this.itemCount, required this.excludedDownloadedCount, required this.isScanning, + required this.scanIsFinalizing, required this.scanProgress, this.scanCurrentFile, required this.scanTotalFiles, @@ -680,6 +685,11 @@ class _LibraryHeroCard extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; + final showIndeterminateProgress = + isScanning && + (scanIsFinalizing || + scanTotalFiles <= 0 || + (scannedFiles <= 0 && scanProgress <= 0)); final displayCount = isScanning ? scannedFiles : itemCount + excludedDownloadedCount; @@ -798,7 +808,7 @@ class _LibraryHeroCard extends StatelessWidget { const SizedBox(height: 4), Text( isScanning - ? context.l10n.libraryTracksUnit(scannedFiles) + ? context.l10n.libraryFilesUnit(scannedFiles) : context.l10n.libraryTracksUnit(displayCount), style: TextStyle( fontSize: 16, @@ -821,14 +831,49 @@ class _LibraryHeroCard extends StatelessWidget { ), ), ], - if (isScanning && scanCurrentFile != null) ...[ + if (isScanning) ...[ const SizedBox(height: 16), LinearProgressIndicator( - value: scanProgress / 100, + value: showIndeterminateProgress + ? null + : scanProgress / 100, backgroundColor: colorScheme.surfaceContainerHighest, color: colorScheme.primary, borderRadius: BorderRadius.circular(4), ), + const SizedBox(height: 8), + Text( + scanIsFinalizing + ? context.l10n.libraryScanFinalizing + : scanTotalFiles > 0 + ? context.l10n.libraryScanProgress( + scanProgress.toStringAsFixed(0), + scanTotalFiles, + ) + : context.l10n.libraryScanning, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.8, + ), + ), + ), + if (!scanIsFinalizing && + scanCurrentFile != null && + scanCurrentFile!.trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + scanCurrentFile!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.7, + ), + ), + ), + ], ] else ...[ const SizedBox(height: 8), Row( @@ -865,14 +910,18 @@ class _LibraryHeroCard extends StatelessWidget { } class _ScanProgressTile extends StatelessWidget { + final bool isFinalizing; final double progress; final String? currentFile; + final int scannedFiles; final int totalFiles; final VoidCallback onCancel; const _ScanProgressTile({ + required this.isFinalizing, required this.progress, this.currentFile, + required this.scannedFiles, required this.totalFiles, required this.onCancel, }); @@ -880,6 +929,8 @@ class _ScanProgressTile extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final showIndeterminateProgress = + isFinalizing || totalFiles <= 0 || (scannedFiles <= 0 && progress <= 0); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), @@ -901,10 +952,14 @@ class _ScanProgressTile extends StatelessWidget { ), ), Text( - context.l10n.libraryScanProgress( - progress.toStringAsFixed(0), - totalFiles, - ), + isFinalizing + ? context.l10n.libraryScanFinalizing + : totalFiles > 0 + ? context.l10n.libraryScanProgress( + progress.toStringAsFixed(0), + totalFiles, + ) + : context.l10n.libraryScanning, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -920,12 +975,14 @@ class _ScanProgressTile extends StatelessWidget { ), const SizedBox(height: 8), LinearProgressIndicator( - value: progress / 100, + value: showIndeterminateProgress ? null : progress / 100, backgroundColor: colorScheme.surfaceContainerHighest, color: colorScheme.primary, borderRadius: BorderRadius.circular(4), ), - if (currentFile != null) ...[ + if (!isFinalizing && + currentFile != null && + currentFile!.trim().isNotEmpty) ...[ const SizedBox(height: 4), Text( currentFile!, diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 6484424..47792a4 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; -import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class OptionsSettingsPage extends ConsumerWidget { @@ -115,7 +116,22 @@ class OptionsSettingsPage extends ConsumerWidget { value: settings.embedMetadata, onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedMetadata(v), + showDivider: settings.embedMetadata, ), + if (settings.embedMetadata) + SettingsItem( + icon: Icons.people_alt_outlined, + title: context.l10n.optionsArtistTagMode, + subtitle: _getArtistTagModeLabel( + context, + settings.artistTagMode, + ), + onTap: () => _showArtistTagModePicker( + context, + ref, + settings.artistTagMode, + ), + ), SettingsSwitchItem( icon: Icons.image, title: context.l10n.optionsMaxQualityCover, @@ -236,6 +252,88 @@ class OptionsSettingsPage extends ConsumerWidget { ); } + String _getArtistTagModeLabel(BuildContext context, String mode) { + switch (mode) { + case artistTagModeSplitVorbis: + return context.l10n.optionsArtistTagModeSplitVorbis; + default: + return context.l10n.optionsArtistTagModeJoined; + } + } + + void _showArtistTagModePicker( + BuildContext context, + WidgetRef ref, + String currentMode, + ) { + final colorScheme = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + context.l10n.optionsArtistTagMode, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.optionsArtistTagModeDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ListTile( + leading: const Icon(Icons.segment_outlined), + title: Text(context.l10n.optionsArtistTagModeJoined), + subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle), + trailing: currentMode == artistTagModeJoined + ? const Icon(Icons.check) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setArtistTagMode(artistTagModeJoined); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.library_music_outlined), + title: Text(context.l10n.optionsArtistTagModeSplitVorbis), + subtitle: Text( + context.l10n.optionsArtistTagModeSplitVorbisSubtitle, + ), + trailing: currentMode == artistTagModeSplitVorbis + ? const Icon(Icons.check) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setArtistTagMode(artistTagModeSplitVorbis); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + void _showClearHistoryDialog( BuildContext context, WidgetRef ref, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 55c7316..dff2f25 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -1838,6 +1838,7 @@ class _TrackMetadataScreenState extends ConsumerState { } } catch (_) {} + final artistTagMode = ref.read(settingsProvider).artistTagMode; String? ffmpegResult; if (isMp3) { ffmpegResult = await FFmpegService.embedMetadataToMp3( @@ -1856,6 +1857,7 @@ class _TrackMetadataScreenState extends ConsumerState { opusPath: workingPath, coverPath: coverPath, metadata: metadata, + artistTagMode: artistTagMode, ); } @@ -2228,6 +2230,7 @@ class _TrackMetadataScreenState extends ConsumerState { if (!_fileExists) return; try { + final artistTagMode = ref.read(settingsProvider).artistTagMode; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.trackReEnrichSearching)), ); @@ -2238,6 +2241,7 @@ class _TrackMetadataScreenState extends ConsumerState { 'cover_url': _coverUrl ?? '', 'max_quality': true, 'embed_lyrics': true, + 'artist_tag_mode': artistTagMode, 'spotify_id': _spotifyId ?? '', 'track_name': trackName, 'artist_name': artistName, @@ -2340,6 +2344,7 @@ class _TrackMetadataScreenState extends ConsumerState { opusPath: ffmpegTarget, coverPath: effectiveCoverPath, metadata: metadata, + artistTagMode: artistTagMode, ); } @@ -3554,6 +3559,7 @@ class _TrackMetadataScreenState extends ConsumerState { bitrate: bitrate, metadata: metadata, coverPath: coverPath, + artistTagMode: ref.read(settingsProvider).artistTagMode, deleteOriginal: !isSaf, ); @@ -3768,6 +3774,7 @@ class _TrackMetadataScreenState extends ConsumerState { initialValues: initialValues, filePath: cleanFilePath, sourceTrackId: _spotifyId, + artistTagMode: ref.read(settingsProvider).artistTagMode, ), ); @@ -3989,12 +3996,14 @@ class _EditMetadataSheet extends StatefulWidget { final Map initialValues; final String filePath; final String? sourceTrackId; + final String artistTagMode; const _EditMetadataSheet({ required this.colorScheme, required this.initialValues, required this.filePath, this.sourceTrackId, + required this.artistTagMode, }); @override @@ -4875,6 +4884,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { 'composer': _composerCtrl.text, 'comment': _commentCtrl.text, 'cover_path': _selectedCoverPath ?? '', + 'artist_tag_mode': widget.artistTagMode, }; try { @@ -5005,6 +5015,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { opusPath: ffmpegTarget, coverPath: existingCoverPath, metadata: vorbisMap, + artistTagMode: widget.artistTagMode, ); } diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index 604e558..29f4237 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -11,6 +11,7 @@ class DownloadRequestPayload { final String filenameFormat; final String quality; final bool embedMetadata; + final String artistTagMode; final bool embedLyrics; final bool embedMaxQualityCover; final int trackNumber; @@ -49,6 +50,7 @@ class DownloadRequestPayload { required this.filenameFormat, this.quality = 'LOSSLESS', this.embedMetadata = true, + this.artistTagMode = 'joined', this.embedLyrics = true, this.embedMaxQualityCover = true, this.trackNumber = 1, @@ -89,6 +91,7 @@ class DownloadRequestPayload { 'filename_format': filenameFormat, 'quality': quality, 'embed_metadata': embedMetadata, + 'artist_tag_mode': artistTagMode, 'embed_lyrics': embedLyrics, 'embed_max_quality_cover': embedMaxQualityCover, 'track_number': trackNumber, @@ -133,6 +136,7 @@ class DownloadRequestPayload { filenameFormat: filenameFormat, quality: quality, embedMetadata: embedMetadata, + artistTagMode: artistTagMode, embedLyrics: embedLyrics, embedMaxQualityCover: embedMaxQualityCover, trackNumber: trackNumber, diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index b2f9016..2496020 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -7,6 +7,7 @@ import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart'; import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; import 'package:ffmpeg_kit_flutter_new_full/session_state.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('FFmpeg'); @@ -887,6 +888,7 @@ class FFmpegService { required String flacPath, String? coverPath, Map? metadata, + String artistTagMode = artistTagModeJoined, }) async { final tempDir = await getTemporaryDirectory(); final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac'); @@ -911,10 +913,11 @@ class FFmpegService { cmdBuffer.write('-c:a copy '); if (metadata != null) { - metadata.forEach((key, value) { - final sanitizedValue = value.replaceAll('"', '\\"'); - cmdBuffer.write('-metadata $key="$sanitizedValue" '); - }); + _appendVorbisMetadataToCommandBuffer( + cmdBuffer, + metadata, + artistTagMode: artistTagMode, + ); } cmdBuffer.write('"$tempOutput" -y'); @@ -1046,6 +1049,7 @@ class FFmpegService { required String opusPath, String? coverPath, Map? metadata, + String artistTagMode = artistTagModeJoined, }) async { final tempDir = await getTemporaryDirectory(); final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus'); @@ -1063,11 +1067,11 @@ class FFmpegService { ]; if (metadata != null) { - metadata.forEach((key, value) { - arguments - ..add('-metadata') - ..add('$key=$value'); - }); + _appendVorbisMetadataToArguments( + arguments, + metadata, + artistTagMode: artistTagMode, + ); } if (coverPath != null) { @@ -1326,6 +1330,7 @@ class FFmpegService { required String bitrate, required Map metadata, String? coverPath, + String artistTagMode = artistTagModeJoined, bool deleteOriginal = true, }) async { final format = targetFormat.toLowerCase(); @@ -1348,6 +1353,7 @@ class FFmpegService { inputPath: inputPath, metadata: metadata, coverPath: coverPath, + artistTagMode: artistTagMode, deleteOriginal: deleteOriginal, ); } @@ -1391,6 +1397,7 @@ class FFmpegService { opusPath: outputPath, coverPath: coverPath, metadata: metadata, + artistTagMode: artistTagMode, ); } @@ -1491,6 +1498,7 @@ class FFmpegService { required String inputPath, required Map metadata, String? coverPath, + String artistTagMode = artistTagModeJoined, bool deleteOriginal = true, }) async { final outputPath = _buildOutputPath(inputPath, '.flac'); @@ -1515,11 +1523,11 @@ class FFmpegService { cmdBuffer.write('-c:a flac -compression_level 8 '); cmdBuffer.write('-map_metadata 0 '); - final vorbisComments = _normalizeToVorbisComments(metadata); - for (final entry in vorbisComments.entries) { - final sanitized = entry.value.replaceAll('"', '\\"'); - cmdBuffer.write('-metadata ${entry.key}="$sanitized" '); - } + _appendVorbisMetadataToCommandBuffer( + cmdBuffer, + metadata, + artistTagMode: artistTagMode, + ); cmdBuffer.write('"$outputPath" -y'); @@ -1617,6 +1625,86 @@ class FFmpegService { return vorbis; } + static void _appendVorbisMetadataToCommandBuffer( + StringBuffer cmdBuffer, + Map metadata, { + String artistTagMode = artistTagModeJoined, + }) { + for (final entry in _buildVorbisMetadataEntries( + metadata, + artistTagMode: artistTagMode, + )) { + final sanitized = entry.value.replaceAll('"', '\\"'); + cmdBuffer.write('-metadata ${entry.key}="$sanitized" '); + } + } + + static void _appendVorbisMetadataToArguments( + List arguments, + Map metadata, { + String artistTagMode = artistTagModeJoined, + }) { + for (final entry in _buildVorbisMetadataEntries( + metadata, + artistTagMode: artistTagMode, + )) { + arguments + ..add('-metadata') + ..add('${entry.key}=${entry.value}'); + } + } + + static List> _buildVorbisMetadataEntries( + Map metadata, { + String artistTagMode = artistTagModeJoined, + }) { + final vorbis = _normalizeToVorbisComments(metadata); + final entries = >[]; + + for (final entry in vorbis.entries) { + if (entry.key == 'ARTIST' || entry.key == 'ALBUMARTIST') { + continue; + } + entries.add(entry); + } + + _appendVorbisArtistEntries( + entries, + 'ARTIST', + vorbis['ARTIST'], + artistTagMode: artistTagMode, + ); + _appendVorbisArtistEntries( + entries, + 'ALBUMARTIST', + vorbis['ALBUMARTIST'], + artistTagMode: artistTagMode, + ); + + return entries; + } + + static void _appendVorbisArtistEntries( + List> entries, + String key, + String? rawValue, { + String artistTagMode = artistTagModeJoined, + }) { + final value = rawValue?.trim() ?? ''; + if (value.isEmpty) { + return; + } + + if (!shouldSplitVorbisArtistTags(artistTagMode)) { + entries.add(MapEntry(key, value)); + return; + } + + for (final artist in splitArtistTagValues(value)) { + entries.add(MapEntry(key, artist)); + } + } + /// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg. static Map _convertToM4aTags(Map metadata) { final m4aMap = {}; diff --git a/lib/utils/artist_utils.dart b/lib/utils/artist_utils.dart index 697a9a8..0c8bcb7 100644 --- a/lib/utils/artist_utils.dart +++ b/lib/utils/artist_utils.dart @@ -3,6 +3,9 @@ final RegExp _artistNameSplitPattern = RegExp( caseSensitive: false, ); +const artistTagModeJoined = 'joined'; +const artistTagModeSplitVorbis = 'split_vorbis'; + List splitArtistNames(String rawArtists) { final raw = rawArtists.trim(); if (raw.isEmpty) return const []; @@ -13,3 +16,25 @@ List splitArtistNames(String rawArtists) { .where((part) => part.isNotEmpty) .toList(growable: false); } + +bool shouldSplitVorbisArtistTags(String mode) { + return mode == artistTagModeSplitVorbis; +} + +List splitArtistTagValues(String rawArtists) { + final seen = {}; + final values = []; + for (final part in splitArtistNames(rawArtists)) { + final key = part.toLowerCase(); + if (seen.add(key)) { + values.add(part); + } + } + + if (values.isNotEmpty) { + return values; + } + + final trimmed = rawArtists.trim(); + return trimmed.isEmpty ? const [] : [trimmed]; +}