diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index f53124d1..5b8f9078 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -31,6 +31,7 @@ const ( LyricsProviderYouTube = "youtube" LyricsProviderKugou = "kugou" LyricsProviderGenius = "genius" + LyricsProviderLyricsPlus = "lyricsplus" ) var DefaultLyricsProviders = []string{ @@ -112,6 +113,7 @@ func SetLyricsProviderOrder(providers []string) { LyricsProviderYouTube: true, LyricsProviderKugou: true, LyricsProviderGenius: true, + LyricsProviderLyricsPlus: true, } var valid []string @@ -151,6 +153,7 @@ func GetAvailableLyricsProviders() []map[string]interface{} { {"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"}, {"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"}, {"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"}, + {"id": LyricsProviderLyricsPlus, "name": "LyricsPlus", "has_proxy_dependency": true, "description": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ)"}, } } @@ -612,6 +615,37 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec) } + case LyricsProviderLyricsPlus: + lyricsPlusClient := NewLyricsPlusClient() + lyrics, err = lyricsPlusClient.FetchLyrics( + trackName, + primaryArtist, + "", + durationSec, + fetchOptions.MultiPersonWordByWord, + fetchOptions.AppleElrcWordSync, + ) + if err != nil && primaryArtist != artistName { + lyrics, err = lyricsPlusClient.FetchLyrics( + trackName, + artistName, + "", + durationSec, + fetchOptions.MultiPersonWordByWord, + fetchOptions.AppleElrcWordSync, + ) + } + if err != nil && simplifiedTrack != trackName { + lyrics, err = lyricsPlusClient.FetchLyrics( + simplifiedTrack, + primaryArtist, + "", + durationSec, + fetchOptions.MultiPersonWordByWord, + fetchOptions.AppleElrcWordSync, + ) + } + default: GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName) continue diff --git a/go_backend/lyrics_lyricsplus.go b/go_backend/lyrics_lyricsplus.go new file mode 100644 index 00000000..1987726c --- /dev/null +++ b/go_backend/lyrics_lyricsplus.go @@ -0,0 +1,243 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// LyricsPlus (KPOE) provider. +// +// LyricsPlus aggregates word-by-word ("karaoke") synced lyrics from Apple +// Music, Musixmatch, Spotify and QQ Music via a community-run backend. It +// frequently has word-level timing for tracks that other providers only offer +// line-synced or not at all. +// +// API: GET {server}/v2/lyrics/get?title=&artist=&album=&duration=&isrc= +// The response is the KPOE JSON format which we convert into the same enhanced +// LRC text the Apple/QQ providers emit, so embedding/export behaves identically. + +// Public LyricsPlus / KPOE servers (mirrors). Tried in order with failover. +// Sourced from the upstream YouLy+ client server list. +var lyricsPlusServers = []string{ + "https://lyricsplus.prjktla.my.id", + "https://lyricsplus.atomix.one", + "https://lyricsplus.binimum.org", + "https://lyricsplus.prjktla.workers.dev", + "https://lyricsplus-seven.vercel.app", + "https://lyrics-plus-backend.vercel.app", +} + +type LyricsPlusClient struct { + httpClient *http.Client +} + +func NewLyricsPlusClient() *LyricsPlusClient { + return &LyricsPlusClient{httpClient: NewMetadataHTTPClient(15 * time.Second)} +} + +type lyricsPlusSyllable struct { + Text string `json:"text"` + Time float64 `json:"time"` // absolute ms + Duration float64 `json:"duration"` // ms + IsBackground bool `json:"isBackground"` +} + +type lyricsPlusLine struct { + Time float64 `json:"time"` // absolute ms + Duration float64 `json:"duration"` // ms + Text string `json:"text"` + Syllabus []lyricsPlusSyllable `json:"syllabus"` +} + +type lyricsPlusResponse struct { + Type string `json:"type"` // "Word" | "Line" | "Syllable" | "None" + Lyrics []lyricsPlusLine `json:"lyrics"` +} + +// FetchLyrics tries each LyricsPlus server in order until one returns usable +// lyrics. multiPersonWordByWord and preserveWordTiming mirror the Apple/QQ +// options so word/background timing is only emitted when the user enabled it. +func (c *LyricsPlusClient) FetchLyrics( + trackName, + artistName, + isrc string, + durationSec float64, + multiPersonWordByWord bool, + preserveWordTiming bool, +) (*LyricsResponse, error) { + if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" { + return nil, fmt.Errorf("lyricsplus: missing track or artist") + } + + var lastErr error + for _, server := range lyricsPlusServers { + lyrics, err := c.fetchFromServer(server, trackName, artistName, isrc, durationSec, multiPersonWordByWord, preserveWordTiming) + if err == nil && lyricsHasUsableText(lyrics) { + return lyrics, nil + } + if err != nil { + lastErr = err + GoLog("[Lyrics] LyricsPlus server %s failed: %v\n", server, err) + } + } + + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("lyricsplus: no lyrics found") +} + +func (c *LyricsPlusClient) fetchFromServer( + server, + trackName, + artistName, + isrc string, + durationSec float64, + multiPersonWordByWord bool, + preserveWordTiming bool, +) (*LyricsResponse, error) { + base := strings.TrimRight(strings.TrimSpace(server), "/") + if base == "" { + return nil, fmt.Errorf("empty server") + } + + params := url.Values{} + params.Set("title", trackName) + params.Set("artist", artistName) + if durationSec > 0 { + params.Set("duration", strconv.FormatFloat(durationSec, 'f', 3, 64)) + } + if strings.TrimSpace(isrc) != "" { + params.Set("isrc", strings.TrimSpace(isrc)) + } + + fullURL := base + "/v2/lyrics/get?" + params.Encode() + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", appUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + // Retry without the ISRC filter, which can be too strict. + if strings.TrimSpace(isrc) != "" { + return c.fetchFromServer(server, trackName, artistName, "", durationSec, multiPersonWordByWord, preserveWordTiming) + } + return nil, fmt.Errorf("lyrics not found") + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var payload lyricsPlusResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("failed to decode lyricsplus response: %w", err) + } + if len(payload.Lyrics) == 0 { + return nil, fmt.Errorf("lyricsplus returned no lines") + } + + lrcText := buildLyricsPlusLRC(&payload, multiPersonWordByWord, preserveWordTiming) + if strings.TrimSpace(lrcText) == "" { + return nil, fmt.Errorf("lyricsplus produced empty lyrics") + } + + lyrics := lyricsResponseFromText(lrcText, "LyricsPlus") + return lyrics, nil +} + +// buildLyricsPlusLRC converts the KPOE JSON into enhanced LRC text. When word +// timing is available and enabled, each syllable is emitted as an inline +// tag (matching the Apple/QQ output); otherwise a line-synced LRC +// is produced from the full line text. +func buildLyricsPlusLRC(resp *lyricsPlusResponse, multiPersonWordByWord bool, preserveWordTiming bool) string { + isWordType := strings.EqualFold(resp.Type, "Word") || strings.EqualFold(resp.Type, "Syllable") + + var sb strings.Builder + first := true + for _, line := range resp.Lyrics { + lineText := line.Text + hasSyllables := len(line.Syllabus) > 0 + + timestamp := msToLRCTimestamp(int64(line.Time)) + + if isWordType && preserveWordTiming && hasSyllables { + mainSyllables := make([]lyricsPlusSyllable, 0, len(line.Syllabus)) + bgSyllables := make([]lyricsPlusSyllable, 0) + for _, syl := range line.Syllabus { + if syl.IsBackground { + bgSyllables = append(bgSyllables, syl) + } else { + mainSyllables = append(mainSyllables, syl) + } + } + if len(mainSyllables) == 0 { + mainSyllables = line.Syllabus + bgSyllables = nil + } + + if !first { + sb.WriteString("\n") + } + first = false + + sb.WriteString(timestamp) + appendLyricsPlusSyllables(&sb, mainSyllables) + + if multiPersonWordByWord && len(bgSyllables) > 0 { + sb.WriteString("\n[bg:") + appendLyricsPlusSyllables(&sb, bgSyllables) + sb.WriteString("]") + } + continue + } + + // Line-synced fallback. Reconstruct text from syllables if needed. + if strings.TrimSpace(lineText) == "" && hasSyllables { + var lineBuilder strings.Builder + for _, syl := range line.Syllabus { + lineBuilder.WriteString(syl.Text) + } + lineText = lineBuilder.String() + } + + lineText = strings.TrimSpace(lineText) + if lineText == "" { + continue + } + + if !first { + sb.WriteString("\n") + } + first = false + + sb.WriteString(timestamp) + sb.WriteString(lineText) + } + + return strings.TrimSpace(sb.String()) +} + +// appendLyricsPlusSyllables writes each syllable as "text". KPOE +// already embeds spacing inside the syllable text, so no extra spaces are added. +func appendLyricsPlusSyllables(sb *strings.Builder, syllables []lyricsPlusSyllable) { + for _, syl := range syllables { + sb.WriteString("<") + sb.WriteString(msToLRCTimestampInline(int64(syl.Time))) + sb.WriteString(">") + sb.WriteString(syl.Text) + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index fbe74e69..5533f423 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -458,6 +458,66 @@ abstract class AppLocalizations { /// **'Disabled: no loudness normalization tags'** String get optionsReplayGainSubtitleOff; + /// Three-dot menu option to scan loudness and write ReplayGain tags + /// + /// In en, this message translates to: + /// **'Rescan ReplayGain'** + String get trackReplayGain; + + /// Subtitle for the rescan ReplayGain menu option + /// + /// In en, this message translates to: + /// **'Analyze loudness and write ReplayGain tags'** + String get trackReplayGainSubtitle; + + /// Snackbar/progress message while scanning ReplayGain for a single track + /// + /// In en, this message translates to: + /// **'Analyzing loudness...'** + String get trackReplayGainScanning; + + /// Snackbar message after ReplayGain tags written for a single track + /// + /// In en, this message translates to: + /// **'ReplayGain tags added'** + String get trackReplayGainSuccess; + + /// Snackbar message when ReplayGain scan/write fails + /// + /// In en, this message translates to: + /// **'Failed to add ReplayGain tags'** + String get trackReplayGainFailed; + + /// Batch selection action button label for ReplayGain + /// + /// In en, this message translates to: + /// **'ReplayGain ({count})'** + String selectionReplayGainCount(int count); + + /// Title of the batch ReplayGain confirmation dialog + /// + /// In en, this message translates to: + /// **'Add ReplayGain'** + String get replayGainBatchConfirmTitle; + + /// Message of the batch ReplayGain confirmation dialog + /// + /// In en, this message translates to: + /// **'Analyze loudness and write ReplayGain tags to {count} track(s)?'** + String replayGainBatchConfirmMessage(int count); + + /// Progress dialog title while batch scanning ReplayGain + /// + /// In en, this message translates to: + /// **'Analyzing ReplayGain...'** + String get replayGainBatchAnalyzing; + + /// Snackbar after batch ReplayGain completes + /// + /// In en, this message translates to: + /// **'ReplayGain added to {success} of {total} tracks'** + String replayGainBatchSuccess(int success, int total); + /// Setting title for how artist metadata is written into files /// /// In en, this message translates to: @@ -4885,6 +4945,12 @@ abstract class AppLocalizations { /// **'QQ Music (good for Chinese songs, via proxy)'** String get lyricsProviderQqMusicDesc; + /// Description for LyricsPlus provider + /// + /// In en, this message translates to: + /// **'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'** + String get lyricsProviderLyricsPlusDesc; + /// Generic description for extension-based lyrics providers /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 021c6684..d95a94a8 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -187,6 +187,43 @@ class AppLocalizationsAr extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Disabled: no loudness normalization tags'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Artist Tag Mode'; @@ -2821,6 +2858,10 @@ class AppLocalizationsAr extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (good for Chinese songs, via proxy)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Extension provider'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 284709ae..ca6cb9fe 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -190,6 +190,43 @@ class AppLocalizationsDe extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Deaktiviert: keine Lautstärke-Normalisierungs-Tags'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Künstler Tag-Modus'; @@ -2857,6 +2894,10 @@ class AppLocalizationsDe extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (gut für chinesische Lieder, via Proxy)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Erweiterungsanbieter'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e7513c36..d0b0b8e9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -187,6 +187,43 @@ class AppLocalizationsEn extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Disabled: no loudness normalization tags'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Artist Tag Mode'; @@ -2821,6 +2858,10 @@ class AppLocalizationsEn extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (good for Chinese songs, via proxy)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Extension provider'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 322386d7..9d563d0d 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -187,6 +187,43 @@ class AppLocalizationsEs extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Disabled: no loudness normalization tags'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Artist Tag Mode'; @@ -2821,6 +2858,10 @@ class AppLocalizationsEs extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (good for Chinese songs, via proxy)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Extension provider'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index a403c0e2..ae7e6e48 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -191,6 +191,43 @@ class AppLocalizationsFr extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Désactivé : aucune balise de normalisation du volume'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Mode « Artiste »'; @@ -2897,6 +2934,10 @@ class AppLocalizationsFr extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (idéal pour écouter des titres chinois, via un proxy)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Fournisseur d\'extensions'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 501a0e17..d9ab9c3a 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -187,6 +187,43 @@ class AppLocalizationsHi extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Disabled: no loudness normalization tags'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Artist Tag Mode'; @@ -2821,6 +2858,10 @@ class AppLocalizationsHi extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (good for Chinese songs, via proxy)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Extension provider'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 20f56f04..791f9490 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -188,6 +188,43 @@ class AppLocalizationsId extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Dinonaktifkan: tidak ada tag normalisasi kenyaringan'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Mode Tag Artis'; @@ -2828,6 +2865,10 @@ class AppLocalizationsId extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (good for Chinese songs, via proxy)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Extension provider'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index bb003e3e..5a0b9c04 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -186,6 +186,43 @@ class AppLocalizationsJa extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Disabled: no loudness normalization tags'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Artist Tag Mode'; @@ -2809,6 +2846,10 @@ class AppLocalizationsJa extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (good for Chinese songs, via proxy)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Extension provider'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index bc2cf0e6..63ae3f73 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -184,6 +184,43 @@ class AppLocalizationsKo extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Disabled: no loudness normalization tags'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Artist Tag Mode'; @@ -2806,6 +2843,10 @@ class AppLocalizationsKo extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (good for Chinese songs, via proxy)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Extension provider'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index ac1c497e..e576faad 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -187,6 +187,43 @@ class AppLocalizationsNl extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Disabled: no loudness normalization tags'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Artist Tag Mode'; @@ -2821,6 +2858,10 @@ class AppLocalizationsNl extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (good for Chinese songs, via proxy)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Extension provider'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index a0ff96f0..a95f93c4 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -187,6 +187,43 @@ class AppLocalizationsPt extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Disabled: no loudness normalization tags'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Artist Tag Mode'; @@ -2821,6 +2858,10 @@ class AppLocalizationsPt extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (good for Chinese songs, via proxy)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Extension provider'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index f3e8d060..e7c2283c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -190,6 +190,43 @@ class AppLocalizationsRu extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Disabled: no loudness normalization tags'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Artist Tag Mode'; @@ -2876,6 +2913,10 @@ class AppLocalizationsRu extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Музыка (хорошо подходит для китайских песен, через прокси)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Поставщик расширений'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index c170a6c0..2af388d9 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -190,6 +190,43 @@ class AppLocalizationsTr extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Devre dışı: Ses normalleştirme etiketi yok'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Sanatçı Etiketi Modu'; @@ -2850,6 +2887,10 @@ class AppLocalizationsTr extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (good for Chinese songs, via proxy)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Extension provider'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 1f8aac62..1b99e0b2 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -191,6 +191,43 @@ class AppLocalizationsUk extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Вимкнено: немає тегів нормалізації гучності'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Режим тегу виконавця'; @@ -2865,6 +2902,10 @@ class AppLocalizationsUk extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (добре для китайських пісень, через проксі)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Постачальник розширень'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9506d4f5..b329b782 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -187,6 +187,43 @@ class AppLocalizationsZh extends AppLocalizations { String get optionsReplayGainSubtitleOff => 'Disabled: no loudness normalization tags'; + @override + String get trackReplayGain => 'Rescan ReplayGain'; + + @override + String get trackReplayGainSubtitle => + 'Analyze loudness and write ReplayGain tags'; + + @override + String get trackReplayGainScanning => 'Analyzing loudness...'; + + @override + String get trackReplayGainSuccess => 'ReplayGain tags added'; + + @override + String get trackReplayGainFailed => 'Failed to add ReplayGain tags'; + + @override + String selectionReplayGainCount(int count) { + return 'ReplayGain ($count)'; + } + + @override + String get replayGainBatchConfirmTitle => 'Add ReplayGain'; + + @override + String replayGainBatchConfirmMessage(int count) { + return 'Analyze loudness and write ReplayGain tags to $count track(s)?'; + } + + @override + String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...'; + + @override + String replayGainBatchSuccess(int success, int total) { + return 'ReplayGain added to $success of $total tracks'; + } + @override String get optionsArtistTagMode => 'Artist Tag Mode'; @@ -2821,6 +2858,10 @@ class AppLocalizationsZh extends AppLocalizations { String get lyricsProviderQqMusicDesc => 'QQ Music (good for Chinese songs, via proxy)'; + @override + String get lyricsProviderLyricsPlusDesc => + 'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'; + @override String get lyricsProviderExtensionDesc => 'Extension provider'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 1e3fcb39..a89cf24a 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -226,6 +226,64 @@ "@optionsReplayGainSubtitleOff": { "description": "Subtitle when ReplayGain is disabled" }, + "trackReplayGain": "Rescan ReplayGain", + "@trackReplayGain": { + "description": "Three-dot menu option to scan loudness and write ReplayGain tags" + }, + "trackReplayGainSubtitle": "Analyze loudness and write ReplayGain tags", + "@trackReplayGainSubtitle": { + "description": "Subtitle for the rescan ReplayGain menu option" + }, + "trackReplayGainScanning": "Analyzing loudness...", + "@trackReplayGainScanning": { + "description": "Snackbar/progress message while scanning ReplayGain for a single track" + }, + "trackReplayGainSuccess": "ReplayGain tags added", + "@trackReplayGainSuccess": { + "description": "Snackbar message after ReplayGain tags written for a single track" + }, + "trackReplayGainFailed": "Failed to add ReplayGain tags", + "@trackReplayGainFailed": { + "description": "Snackbar message when ReplayGain scan/write fails" + }, + "selectionReplayGainCount": "ReplayGain ({count})", + "@selectionReplayGainCount": { + "description": "Batch selection action button label for ReplayGain", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "replayGainBatchConfirmTitle": "Add ReplayGain", + "@replayGainBatchConfirmTitle": { + "description": "Title of the batch ReplayGain confirmation dialog" + }, + "replayGainBatchConfirmMessage": "Analyze loudness and write ReplayGain tags to {count} track(s)?", + "@replayGainBatchConfirmMessage": { + "description": "Message of the batch ReplayGain confirmation dialog", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "replayGainBatchAnalyzing": "Analyzing ReplayGain...", + "@replayGainBatchAnalyzing": { + "description": "Progress dialog title while batch scanning ReplayGain" + }, + "replayGainBatchSuccess": "ReplayGain added to {success} of {total} tracks", + "@replayGainBatchSuccess": { + "description": "Snackbar after batch ReplayGain completes", + "placeholders": { + "success": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "optionsArtistTagMode": "Artist Tag Mode", "@optionsArtistTagMode": { "description": "Setting title for how artist metadata is written into files" @@ -3737,6 +3795,10 @@ "@lyricsProviderQqMusicDesc": { "description": "Description for QQ Music provider" }, + "lyricsProviderLyricsPlusDesc": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)", + "@lyricsProviderLyricsPlusDesc": { + "description": "Description for LyricsPlus provider" + }, "lyricsProviderExtensionDesc": "Extension provider", "@lyricsProviderExtensionDesc": { "description": "Generic description for extension-based lyrics providers" diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 33573ac4..9317452b 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -7,6 +7,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/replaygain_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -1412,6 +1413,80 @@ class _DownloadedAlbumScreenState extends ConsumerState { } } + Future _runBatchReplayGain(List tracks) async { + final tracksById = {for (final t in tracks) t.id: t}; + final selected = []; + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) continue; + selected.add(item); + } + + if (selected.isEmpty) return; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(ctx.l10n.replayGainBatchConfirmTitle), + content: Text( + ctx.l10n.replayGainBatchConfirmMessage(selected.length), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(ctx.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(ctx.l10n.replayGainBatchConfirmTitle), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + var cancelled = false; + int successCount = 0; + final total = selected.length; + + BatchProgressDialog.show( + context: context, + title: context.l10n.replayGainBatchAnalyzing, + total: total, + icon: Icons.graphic_eq, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + for (int i = 0; i < total; i++) { + if (!mounted || cancelled) break; + final item = selected[i]; + BatchProgressDialog.update(current: i + 1, detail: item.trackName); + try { + final ok = await ReplayGainService.applyToFile(item.filePath); + if (ok) successCount++; + } catch (_) {} + } + + _exitSelectionMode(); + + if (!mounted) return; + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.replayGainBatchSuccess(successCount, total), + ), + ), + ); + } + Widget _buildSelectionBottomBar( BuildContext context, ColorScheme colorScheme, @@ -1508,10 +1583,12 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _DownloadedAlbumSelectionActionButton( + LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final itemWidth = (constraints.maxWidth - spacing) / 2; + final actions = [ + _DownloadedAlbumSelectionActionButton( icon: Icons.share_outlined, label: context.l10n.selectionShareCount(selectedCount), onPressed: selectedCount > 0 @@ -1519,10 +1596,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { : null, colorScheme: colorScheme, ), - ), - const SizedBox(width: 8), - Expanded( - child: _DownloadedAlbumSelectionActionButton( + _DownloadedAlbumSelectionActionButton( icon: Icons.swap_horiz, label: context.l10n.selectionConvertCount(selectedCount), onPressed: selectedCount > 0 @@ -1530,8 +1604,27 @@ class _DownloadedAlbumScreenState extends ConsumerState { : null, colorScheme: colorScheme, ), - ), - ], + _DownloadedAlbumSelectionActionButton( + icon: Icons.graphic_eq, + label: context.l10n.selectionReplayGainCount( + selectedCount, + ), + onPressed: selectedCount > 0 + ? () => _runBatchReplayGain(tracks) + : null, + colorScheme: colorScheme, + ), + ]; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final action in actions) + SizedBox(width: itemWidth, child: action), + ], + ); + }, ), const SizedBox(height: 8), diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index b38bc0c9..3384235b 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/replaygain_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'; @@ -1681,6 +1682,80 @@ class _LocalAlbumScreenState extends ConsumerState { } } + Future _runBatchReplayGain(List tracks) async { + final tracksById = {for (final t in tracks) t.id: t}; + final selected = []; + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) continue; + selected.add(item); + } + + if (selected.isEmpty) return; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(ctx.l10n.replayGainBatchConfirmTitle), + content: Text( + ctx.l10n.replayGainBatchConfirmMessage(selected.length), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(ctx.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(ctx.l10n.replayGainBatchConfirmTitle), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + var cancelled = false; + int successCount = 0; + final total = selected.length; + + BatchProgressDialog.show( + context: context, + title: context.l10n.replayGainBatchAnalyzing, + total: total, + icon: Icons.graphic_eq, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + for (int i = 0; i < total; i++) { + if (!mounted || cancelled) break; + final item = selected[i]; + BatchProgressDialog.update(current: i + 1, detail: item.trackName); + try { + final ok = await ReplayGainService.applyToFile(item.filePath); + if (ok) successCount++; + } catch (_) {} + } + + _exitSelectionMode(); + + if (!mounted) return; + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.replayGainBatchSuccess(successCount, total), + ), + ), + ); + } + Widget _buildSelectionBottomBar( BuildContext context, ColorScheme colorScheme, @@ -1778,22 +1853,26 @@ class _LocalAlbumScreenState extends ConsumerState { ), const SizedBox(height: 12), - Row( - children: [ - if (flacEligibleCount > 0) ...[ - Expanded( - child: _LocalAlbumSelectionActionButton( + LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final itemWidth = (constraints.maxWidth - spacing) / 2; + final actions = []; + + if (flacEligibleCount > 0) { + actions.add( + _LocalAlbumSelectionActionButton( icon: Icons.download_for_offline_outlined, label: '${context.l10n.queueFlacAction} ($flacEligibleCount)', onPressed: () => _queueSelectedAsFlac(tracks), colorScheme: colorScheme, ), - ), - const SizedBox(width: 8), - ], - Expanded( - child: _LocalAlbumSelectionActionButton( + ); + } + + actions.add( + _LocalAlbumSelectionActionButton( icon: Icons.auto_fix_high_outlined, label: '${context.l10n.trackReEnrich} ($selectedCount)', onPressed: selectedCount > 0 @@ -1801,10 +1880,10 @@ class _LocalAlbumScreenState extends ConsumerState { : null, colorScheme: colorScheme, ), - ), - const SizedBox(width: 8), - Expanded( - child: _LocalAlbumSelectionActionButton( + ); + + actions.add( + _LocalAlbumSelectionActionButton( icon: Icons.swap_horiz, label: context.l10n.selectionConvertCount(selectedCount), onPressed: selectedCount > 0 @@ -1812,8 +1891,30 @@ class _LocalAlbumScreenState extends ConsumerState { : null, colorScheme: colorScheme, ), - ), - ], + ); + + actions.add( + _LocalAlbumSelectionActionButton( + icon: Icons.graphic_eq, + label: context.l10n.selectionReplayGainCount( + selectedCount, + ), + onPressed: selectedCount > 0 + ? () => _runBatchReplayGain(tracks) + : null, + colorScheme: colorScheme, + ), + ); + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final action in actions) + SizedBox(width: itemWidth, child: action), + ], + ); + }, ), const SizedBox(height: 8), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 301ef3a5..63bb5406 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/replaygain_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; @@ -5421,6 +5422,95 @@ class _QueueTabState extends ConsumerState { } } + /// Batch-scan loudness and write ReplayGain tags to the selected tracks. + Future _runBatchReplayGain( + List allItems, + ) async { + final itemsById = {for (final item in allItems) item.id: item}; + final selectedItems = []; + for (final id in _selectedIds) { + final item = itemsById[id]; + if (item == null) continue; + selectedItems.add(item); + } + + if (selectedItems.isEmpty) return; + + _hideSelectionOverlay(); + _hidePlaylistSelectionOverlay(); + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(ctx.l10n.replayGainBatchConfirmTitle), + content: Text( + ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(ctx.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(ctx.l10n.replayGainBatchConfirmTitle), + ), + ], + ), + ); + + if (!mounted) return; + if (confirmed != true) { + if (_isSelectionMode) { + _syncSelectionOverlay( + items: allItems, + bottomPadding: MediaQuery.of(context).padding.bottom, + ); + } + return; + } + + var cancelled = false; + int successCount = 0; + final total = selectedItems.length; + + BatchProgressDialog.show( + context: context, + title: context.l10n.replayGainBatchAnalyzing, + total: total, + icon: Icons.graphic_eq, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + for (int i = 0; i < total; i++) { + if (!mounted || cancelled) break; + final item = selectedItems[i]; + BatchProgressDialog.update(current: i + 1, detail: item.trackName); + try { + final ok = await ReplayGainService.applyToFile(item.filePath); + if (ok) successCount++; + } catch (_) {} + } + + _exitSelectionMode(); + + if (!mounted) return; + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.replayGainBatchSuccess(successCount, total), + ), + ), + ); + } + Widget _buildSelectionBottomBar( BuildContext context, ColorScheme colorScheme, @@ -5524,11 +5614,15 @@ class _QueueTabState extends ConsumerState { const SizedBox(height: 12), - Row( - children: [ - if (localOnlySelection && flacEligibleCount > 0) ...[ - Expanded( - child: _SelectionActionButton( + LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final itemWidth = (constraints.maxWidth - spacing) / 2; + final actions = []; + + if (localOnlySelection && flacEligibleCount > 0) { + actions.add( + _SelectionActionButton( icon: Icons.download_for_offline_outlined, label: '${context.l10n.queueFlacAction} ($flacEligibleCount)', @@ -5536,11 +5630,11 @@ class _QueueTabState extends ConsumerState { _queueSelectedLocalAsFlac(unifiedItems), colorScheme: colorScheme, ), - ), - const SizedBox(width: 8), - ], - Expanded( - child: _SelectionActionButton( + ); + } + + actions.add( + _SelectionActionButton( icon: localOnlySelection ? Icons.auto_fix_high_outlined : Icons.share_outlined, @@ -5554,10 +5648,10 @@ class _QueueTabState extends ConsumerState { : null, colorScheme: colorScheme, ), - ), - const SizedBox(width: 8), - Expanded( - child: _SelectionActionButton( + ); + + actions.add( + _SelectionActionButton( icon: Icons.swap_horiz, label: context.l10n.selectionConvertCount(selectedCount), onPressed: selectedCount > 0 @@ -5565,8 +5659,30 @@ class _QueueTabState extends ConsumerState { : null, colorScheme: colorScheme, ), - ), - ], + ); + + actions.add( + _SelectionActionButton( + icon: Icons.graphic_eq, + label: context.l10n.selectionReplayGainCount( + selectedCount, + ), + onPressed: selectedCount > 0 + ? () => _runBatchReplayGain(unifiedItems) + : null, + colorScheme: colorScheme, + ), + ); + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final action in actions) + SizedBox(width: itemWidth, child: action), + ], + ); + }, ), const SizedBox(height: 8), diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart index ae586237..498e4b56 100644 --- a/lib/screens/settings/lyrics_provider_priority_page.dart +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -26,6 +26,7 @@ class _LyricsProviderPriorityPageState 'youtube', 'kugou', 'genius', + 'lyricsplus', ]; late List _enabledProviders; @@ -245,6 +246,12 @@ class _LyricsProviderPriorityPageState description: context.l10n.lyricsProviderExtensionDesc, icon: Icons.auto_awesome_outlined, ); + case 'lyricsplus': + return _LyricsProviderInfo( + name: 'LyricsPlus', + description: context.l10n.lyricsProviderLyricsPlusDesc, + icon: Icons.lyrics_outlined, + ); default: return _LyricsProviderInfo( name: id, diff --git a/lib/screens/settings/lyrics_settings_page.dart b/lib/screens/settings/lyrics_settings_page.dart index 8766230d..6358d7e4 100644 --- a/lib/screens/settings/lyrics_settings_page.dart +++ b/lib/screens/settings/lyrics_settings_page.dart @@ -221,6 +221,7 @@ class LyricsSettingsPage extends ConsumerWidget { 'youtube': 'YouTube', 'kugou': 'Kugou', 'genius': 'Genius', + 'lyricsplus': 'LyricsPlus', }; String _getLyricsProvidersSubtitle( diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 28debca7..82ce75cc 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/replaygain_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -3259,136 +3260,196 @@ class _TrackMetadataScreenState extends ConsumerState { showModalBottomSheet( context: screenContext, useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), isScrollControlled: true, - constraints: BoxConstraints( - maxHeight: MediaQuery.of(screenContext).size.height * 0.7, - ), - builder: (sheetContext) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(height: 16), - ListTile( - leading: const Icon(Icons.copy), - title: Text(sheetContext.l10n.trackCopyFilePath), - onTap: () { - _closeOptionsMenuAndRun( - sheetContext, - () => _copyToClipboard(screenContext, cleanFilePath), - ); - }, - ), - if (_fileExists) - ListTile( - leading: const Icon(Icons.edit_outlined), - title: Text(sheetContext.l10n.trackEditMetadata), - onTap: () { - _closeOptionsMenuAndRun( - sheetContext, - () => _showEditMetadataSheet( - screenContext, - ref, - colorScheme, - ), - ); - }, - ), - if (!_isLocalItem && (_coverUrl != null || _fileExists)) - ListTile( - leading: const Icon(Icons.image_outlined), - title: Text(sheetContext.l10n.trackSaveCoverArt), - subtitle: Text(sheetContext.l10n.trackSaveCoverArtSubtitle), - onTap: () { - _closeOptionsMenuAndRun(sheetContext, _saveCoverArt); - }, - ), - if (!_isLocalItem) - ListTile( - leading: const Icon(Icons.lyrics_outlined), - title: Text(sheetContext.l10n.trackSaveLyrics), - subtitle: Text(sheetContext.l10n.trackSaveLyricsSubtitle), - onTap: () { - _closeOptionsMenuAndRun(sheetContext, _saveLyrics); - }, - ), - if (_fileExists) - ListTile( - leading: const Icon(Icons.travel_explore), - title: Text(sheetContext.l10n.trackReEnrich), - subtitle: Text(sheetContext.l10n.trackReEnrichOnlineSubtitle), - onTap: () { - _closeOptionsMenuAndRun(sheetContext, _reEnrichMetadata); - }, - ), - if (_fileExists && _isConvertibleFormat) - ListTile( - leading: const Icon(Icons.swap_horiz), - title: Text(sheetContext.l10n.trackConvertFormat), - subtitle: Text(sheetContext.l10n.trackConvertFormatSubtitle), - onTap: () { - _closeOptionsMenuAndRun( - sheetContext, - () => _showConvertSheet(screenContext), - ); - }, - ), - if (_fileExists && _isCueFile) - ListTile( - leading: const Icon(Icons.call_split), - title: Text(sheetContext.l10n.cueSplitTitle), - subtitle: Text(sheetContext.l10n.cueSplitSubtitle), - onTap: () { - _closeOptionsMenuAndRun( - sheetContext, - () => _showCueSplitSheet(screenContext), - ); - }, - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.share), - title: Text(sheetContext.l10n.trackMetadataShare), - onTap: () { - _closeOptionsMenuAndRun( - sheetContext, - () => _shareFile(screenContext), - ); - }, - ), - ListTile( - leading: Icon(Icons.delete, color: colorScheme.error), - title: Text( - sheetContext.l10n.trackRemoveFromDevice, - style: TextStyle(color: colorScheme.error), - ), - onTap: () { - _closeOptionsMenuAndRun( - sheetContext, - () => _confirmDelete(screenContext, ref, colorScheme), - ); - }, - ), - const SizedBox(height: 16), - ], + builder: (sheetContext) { + final l10n = sheetContext.l10n; + + final options = <_MetadataOption>[ + _MetadataOption( + icon: Icons.copy_outlined, + label: l10n.trackCopyFilePath, + onTap: () => _copyToClipboard(screenContext, cleanFilePath), ), - ), - ), + if (_fileExists) + _MetadataOption( + icon: Icons.edit_outlined, + label: l10n.trackEditMetadata, + onTap: () => + _showEditMetadataSheet(screenContext, ref, colorScheme), + ), + if (!_isLocalItem && (_coverUrl != null || _fileExists)) + _MetadataOption( + icon: Icons.image_outlined, + label: l10n.trackSaveCoverArt, + onTap: _saveCoverArt, + ), + if (!_isLocalItem) + _MetadataOption( + icon: Icons.lyrics_outlined, + label: l10n.trackSaveLyrics, + onTap: _saveLyrics, + ), + if (_fileExists) + _MetadataOption( + icon: Icons.travel_explore, + label: l10n.trackReEnrich, + onTap: _reEnrichMetadata, + ), + if (_fileExists && _isConvertibleFormat) + _MetadataOption( + icon: Icons.swap_horiz, + label: l10n.trackConvertFormat, + onTap: () => _showConvertSheet(screenContext), + ), + if (_fileExists && !_isCueFile) + _MetadataOption( + icon: Icons.graphic_eq, + label: l10n.trackReplayGain, + onTap: () => _rescanReplayGain(), + ), + if (_fileExists && _isCueFile) + _MetadataOption( + icon: Icons.call_split, + label: l10n.cueSplitTitle, + onTap: () => _showCueSplitSheet(screenContext), + ), + _MetadataOption( + icon: Icons.share_outlined, + label: l10n.trackMetadataShare, + onTap: () => _shareFile(screenContext), + ), + _MetadataOption( + icon: Icons.delete_outline, + label: l10n.trackRemoveFromDevice, + destructive: true, + onTap: () => _confirmDelete(screenContext, ref, colorScheme), + ), + ]; + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(sheetContext).size.height * 0.85, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Center( + child: 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: _buildOptionsHeaderCover(colorScheme), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(sheetContext) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + Text( + artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(sheetContext) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: 4), + for (final option in options) + _MetadataOptionTile( + option: option, + colorScheme: colorScheme, + onTap: () => + _closeOptionsMenuAndRun(sheetContext, option.onTap), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + }, ); } + Widget _buildOptionsHeaderCover(ColorScheme colorScheme) { + const size = 56.0; + const cacheWidth = 112; + + Widget placeholder() => Container( + width: size, + height: size, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ); + + if (_coverUrl != null) { + return CachedCoverImage( + imageUrl: _coverUrl!, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + errorWidget: (_, _, _) => placeholder(), + ); + } + + if (_localCoverPath != null && _localCoverPath!.isNotEmpty) { + return Image.file( + File(_localCoverPath!), + width: size, + height: size, + fit: BoxFit.cover, + cacheWidth: cacheWidth, + errorBuilder: (_, _, _) => placeholder(), + ); + } + + return placeholder(); + } + /// Whether the current file format supports conversion bool get _isConvertibleFormat { final lower = cleanFilePath.toLowerCase(); @@ -3572,6 +3633,35 @@ class _TrackMetadataScreenState extends ConsumerState { return normalized; } + Future _rescanReplayGain() async { + if (!_fileExists) return; + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + content: Text(context.l10n.trackReplayGainScanning), + duration: const Duration(seconds: 30), + ), + ); + bool ok = false; + try { + ok = await ReplayGainService.applyToFile(cleanFilePath); + } catch (e) { + _log.w('ReplayGain rescan failed: $e'); + } + if (!mounted) return; + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text( + ok + ? context.l10n.trackReplayGainSuccess + : context.l10n.trackReplayGainFailed, + ), + ), + ); + } + void _showConvertSheet(BuildContext context) { final currentFormat = _currentFileFormat; final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A'; @@ -4859,3 +4949,57 @@ class _TrackMetadataScreenState extends ConsumerState { } } } + +class _MetadataOption { + final IconData icon; + final String label; + final VoidCallback onTap; + final bool destructive; + + const _MetadataOption({ + required this.icon, + required this.label, + required this.onTap, + this.destructive = false, + }); +} + +class _MetadataOptionTile extends StatelessWidget { + final _MetadataOption option; + final ColorScheme colorScheme; + final VoidCallback onTap; + + const _MetadataOptionTile({ + required this.option, + required this.colorScheme, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final boxColor = option.destructive + ? colorScheme.errorContainer + : colorScheme.primaryContainer; + final iconColor = option.destructive + ? colorScheme.onErrorContainer + : colorScheme.onPrimaryContainer; + final titleColor = option.destructive ? colorScheme.error : null; + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: boxColor, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(option.icon, color: iconColor, size: 20), + ), + title: Text( + option.label, + style: TextStyle(fontWeight: FontWeight.w500, color: titleColor), + ), + onTap: onTap, + ); + } +} diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 1a15191a..657bea4f 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1292,6 +1292,71 @@ class FFmpegService { return false; } + /// Write track ReplayGain tags to a file via FFmpeg, replacing it in place. + /// + /// Used for formats that are not handled by the native tag writers + /// (MP3/Opus). All existing streams and metadata are preserved via + /// `-map 0 -c copy -map_metadata 0`; only the REPLAYGAIN_TRACK_* fields are + /// added/overwritten. Returns `true` when the file was rewritten in place. + static Future writeTrackReplayGainTags( + String filePath, + String trackGain, + String trackPeak, + ) async { + final ext = filePath.contains('.') + ? '.${filePath.split('.').last}' + : '.tmp'; + final tempDir = await getTemporaryDirectory(); + final tempOutput = _nextTempEmbedPath(tempDir.path, ext); + final arguments = [ + '-v', + 'error', + '-hide_banner', + '-i', + filePath, + '-map', + '0', + '-c', + 'copy', + '-map_metadata', + '0', + '-metadata', + 'REPLAYGAIN_TRACK_GAIN=$trackGain', + '-metadata', + 'REPLAYGAIN_TRACK_PEAK=$trackPeak', + tempOutput, + '-y', + ]; + + _log.d('Writing track ReplayGain tags via FFmpeg'); + final result = await _executeWithArguments(arguments); + + if (result.success) { + try { + final tempFile = File(tempOutput); + if (await tempFile.exists()) { + final originalFile = File(filePath); + if (await originalFile.exists()) { + await originalFile.delete(); + } + await tempFile.copy(filePath); + await tempFile.delete(); + _log.d('Track ReplayGain tags written successfully'); + return true; + } + } catch (e) { + _log.w('Failed to replace file with track ReplayGain: $e'); + } + } + + try { + final tempFile = File(tempOutput); + if (await tempFile.exists()) await tempFile.delete(); + } catch (_) {} + + return false; + } + static Future embedMetadata({ required String flacPath, String? coverPath, diff --git a/lib/services/replaygain_service.dart b/lib/services/replaygain_service.dart new file mode 100644 index 00000000..68e28857 --- /dev/null +++ b/lib/services/replaygain_service.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +/// Standalone ReplayGain (re)scanning for existing audio files. +/// +/// Computes EBU R128 loudness via FFmpeg and writes REPLAYGAIN_TRACK_* tags +/// back into the file in place: +/// - FLAC / M4A / MP4 / APE / WV / MPC -> native tag writer (PlatformBridge) +/// - MP3 / Opus / OGG / others -> FFmpeg copy-with-metadata +/// +/// Handles SAF content:// URIs transparently by working on a temporary copy +/// and writing it back to the original document. +class ReplayGainService { + ReplayGainService._(); + + static final _log = AppLogger('ReplayGain'); + + static const _nativeExtensions = { + '.flac', + '.m4a', + '.mp4', + '.m4b', + '.ape', + '.wv', + '.mpc', + }; + + static bool _isNativeWritableFormat(String path) { + final lower = path.toLowerCase(); + return _nativeExtensions.any(lower.endsWith); + } + + /// Scans [filePath] for loudness and writes track ReplayGain tags in place. + /// + /// Returns `true` when tags were successfully written, `false` otherwise + /// (scan failed, write failed, or SAF write-back failed). + static Future applyToFile(String filePath) async { + if (filePath.isEmpty) return false; + + final isSaf = isContentUri(filePath); + var workingPath = filePath; + String? safTempPath; + + try { + if (isSaf) { + safTempPath = await PlatformBridge.copyContentUriToTemp(filePath); + if (safTempPath == null || safTempPath.isEmpty) { + _log.w('Failed to copy SAF file to temp for ReplayGain scan'); + return false; + } + workingPath = safTempPath; + } + + final rg = await FFmpegService.scanReplayGain(workingPath); + if (rg == null) { + _log.w('ReplayGain scan returned no result for $workingPath'); + return false; + } + + bool written; + if (_isNativeWritableFormat(workingPath)) { + final result = await PlatformBridge.editFileMetadata(workingPath, { + 'replaygain_track_gain': rg.trackGain, + 'replaygain_track_peak': rg.trackPeak, + }); + written = result['error'] == null; + if (!written) { + _log.w('Native ReplayGain write failed: ${result['error']}'); + } + } else { + written = await FFmpegService.writeTrackReplayGainTags( + workingPath, + rg.trackGain, + rg.trackPeak, + ); + } + + if (!written) return false; + + if (isSaf) { + final ok = await PlatformBridge.writeTempToSaf(workingPath, filePath); + if (!ok) { + _log.w('Failed to write ReplayGain temp file back to SAF document'); + } + return ok; + } + + return true; + } catch (e) { + _log.e('Failed to apply ReplayGain', e); + return false; + } finally { + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + } + } +}