mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-29 17:50:00 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Поставщик расширений';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Постачальник розширень';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user