feat(lyrics,replaygain): add LyricsPlus provider and ReplayGain batch scanning

LyricsPlus (KPOE): word-by-word synced lyrics with multi-server failover, converted to enhanced LRC. ReplayGain: standalone EBU R128 (re)scan writing REPLAYGAIN_TRACK_* tags via native writers or FFmpeg, with batch action in queue/album screens and SAF support.
This commit is contained in:
zarzet
2026-06-12 01:59:26 +07:00
parent adea3de737
commit 2a2e2924eb
27 changed files with 1816 additions and 165 deletions
+34
View File
@@ -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
+243
View File
@@ -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
// <mm:ss.xx> 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 "<mm:ss.xx>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)
}
}
+66
View File
@@ -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:
+41
View File
@@ -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';
+41
View File
@@ -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';
+41
View File
@@ -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';
+41
View File
@@ -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';
+41
View File
@@ -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';
+41
View File
@@ -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';
+41
View File
@@ -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';
+41
View File
@@ -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';
+41
View File
@@ -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';
+41
View File
@@ -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';
+41
View File
@@ -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';
+41
View File
@@ -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 => 'Поставщик расширений';
+41
View File
@@ -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';
+41
View File
@@ -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 => 'Постачальник розширень';
+41
View File
@@ -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';
+62
View File
@@ -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"
+103 -10
View File
@@ -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<DownloadedAlbumScreen> {
}
}
Future<void> _runBatchReplayGain(List<DownloadHistoryItem> tracks) async {
final tracksById = {for (final t in tracks) t.id: t};
final selected = <DownloadHistoryItem>[];
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
selected.add(item);
}
if (selected.isEmpty) return;
final confirmed = await showDialog<bool>(
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<DownloadedAlbumScreen> {
),
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 = <Widget>[
_DownloadedAlbumSelectionActionButton(
icon: Icons.share_outlined,
label: context.l10n.selectionShareCount(selectedCount),
onPressed: selectedCount > 0
@@ -1519,10 +1596,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
: 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<DownloadedAlbumScreen> {
: 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),
+117 -16
View File
@@ -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<LocalAlbumScreen> {
}
}
Future<void> _runBatchReplayGain(List<LocalLibraryItem> tracks) async {
final tracksById = {for (final t in tracks) t.id: t};
final selected = <LocalLibraryItem>[];
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
selected.add(item);
}
if (selected.isEmpty) return;
final confirmed = await showDialog<bool>(
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<LocalAlbumScreen> {
),
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 = <Widget>[];
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<LocalAlbumScreen> {
: 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<LocalAlbumScreen> {
: 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),
+132 -16
View File
@@ -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<QueueTab> {
}
}
/// Batch-scan loudness and write ReplayGain tags to the selected tracks.
Future<void> _runBatchReplayGain(
List<UnifiedLibraryItem> allItems,
) async {
final itemsById = {for (final item in allItems) item.id: item};
final selectedItems = <UnifiedLibraryItem>[];
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<bool>(
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<QueueTab> {
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 = <Widget>[];
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<QueueTab> {
_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<QueueTab> {
: 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<QueueTab> {
: 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),
@@ -26,6 +26,7 @@ class _LyricsProviderPriorityPageState
'youtube',
'kugou',
'genius',
'lyricsplus',
];
late List<String> _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,
@@ -221,6 +221,7 @@ class LyricsSettingsPage extends ConsumerWidget {
'youtube': 'YouTube',
'kugou': 'Kugou',
'genius': 'Genius',
'lyricsplus': 'LyricsPlus',
};
String _getLyricsProvidersSubtitle(
+267 -123
View File
@@ -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<TrackMetadataScreen> {
showModalBottomSheet<void>(
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<TrackMetadataScreen> {
return normalized;
}
Future<void> _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<TrackMetadataScreen> {
}
}
}
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,
);
}
}
+65
View File
@@ -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<bool> 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 = <String>[
'-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<String?> embedMetadata({
required String flacPath,
String? coverPath,
+104
View File
@@ -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 = <String>{
'.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<bool> 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 (_) {}
}
}
}
}