Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 813ed79073 | |||
| 537bab69ab | |||
| b0871ad94b | |||
| 0bd7574ab2 | |||
| c3f8b48bf7 | |||
| 90f731ac1e | |||
| 8e6cbcbc2a | |||
| 3ac9ff1dd7 | |||
| 3e90b29d2b | |||
| b74186464b | |||
| f4934dcb28 | |||
| 30973a8e78 | |||
| 9b89625660 | |||
| c70ba5962e | |||
| 8c722b0a18 | |||
| 3ece6770e1 | |||
| 1407018d98 |
@@ -0,0 +1,44 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'site/**'
|
||||
- '.github/workflows/pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: site
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -1,5 +1,105 @@
|
||||
# Changelog
|
||||
|
||||
## [3.6.9] - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- **YouTube Bitrate Presets**: YouTube bitrate selection now uses supported presets only
|
||||
- Opus: 128 / 256 kbps
|
||||
- MP3: 128 / 256 / 320 kbps
|
||||
- **Go Test Coverage for YouTube Quality Parsing**: Added tests for supported-bitrate normalization behavior
|
||||
- **Localization for YouTube Bitrate UI**: Added localized strings (EN/ID) for YouTube bitrate titles and labels
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cover Image Cache Clear Not Working**: Clearing "Cover image cache" now performs a full on-disk wipe, clears in-memory image cache, and reinitializes cache manager state
|
||||
- Prevents stale/orphaned cache files from keeping the same storage usage after clear
|
||||
- **YouTube Queue Fallback Quality Mismatch**: Queue fallback now normalizes YouTube quality IDs so conversion paths use valid bitrate format IDs
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default Lyrics Behavior**: `Apple/QQ Multi-Person Word-by-Word` is now OFF by default for new installs
|
||||
- **Removed Dynamic YouTube Bitrate Mode**: Arbitrary values are now normalized to nearest supported Spotube preset across settings, picker, queue fallback, and Go backend parser
|
||||
- **Lyrics Embedding Control**: Users can now disable the embedded-lyrics process from settings (`Embed Lyrics` off)
|
||||
|
||||
---
|
||||
|
||||
## [3.6.8] - 2026-02-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Lyrics Source Tracking**: Track Metadata screen now displays the source of loaded lyrics (LRCLIB, Musixmatch, Netease, Apple Music, QQ Music, Embedded, or Extension)
|
||||
- New `getLyricsLRCWithSource` API returns lyrics with source metadata
|
||||
- Source badge appears below lyrics section in Track Metadata screen
|
||||
- **Dedicated Lyrics Provider Priority Page**: Lyrics providers can now be configured from a dedicated settings page with full-screen reorderable list
|
||||
- Replaced inline bottom sheet with `LyricsProviderPriorityPage`
|
||||
- Cleaner UI with provider descriptions and priority ordering
|
||||
- **Paxsenix Integration**: Added Paxsenix API as official lyrics proxy partner for Apple Music, QQ Music, Musixmatch, and Netease sources
|
||||
- Listed in About page and Partners page on project site
|
||||
- README updated with partner attribution
|
||||
|
||||
### Fixed
|
||||
|
||||
- **LRC Background Vocal Preservation**: Apple Music/QQ Music `[bg:...]` background vocal tags are now preserved during LRC parsing instead of being stripped
|
||||
- Background vocals attach to the previous timed line in exported LRC files
|
||||
- **LRC Display Improvements**:
|
||||
- Inline word-by-word timestamps (`<mm:ss.xx>`) are stripped from lyrics display
|
||||
- Speaker prefixes (`v1:`, `v2:`) are removed for cleaner display
|
||||
- Multi-line background vocals converted to readable secondary vocal lines
|
||||
- **Apple Music Lyrics Case Sensitivity**: Fixed `lyricsType` comparison to use case-insensitive matching for "Syllable" type
|
||||
|
||||
### Changed
|
||||
|
||||
- Track Metadata lyrics fetching now uses `getLyricsLRCWithSource` for consistent source attribution across embedded and online lyrics
|
||||
|
||||
---
|
||||
|
||||
## [3.6.7] - 2026-02-13
|
||||
|
||||
### Added
|
||||
|
||||
- "Advanced Filename Templates" - new placeholders for custom track/disc formatting and date patterns
|
||||
- `{track_raw}` and `{disc_raw}` - unpadded raw numbers
|
||||
- `{track:N}` and `{disc:N}` - zero-padded to N digits (e.g. `{track:02}` → `01`)
|
||||
- `{date}` - full release date from metadata
|
||||
- `{date:%Y-%m-%d}` - date formatting with strftime patterns
|
||||
- "Show advanced tags" toggle in Settings > Download > Filename Format to reveal these placeholders
|
||||
- Low-RAM / ARM32-only device profiling - detects constrained devices at startup and reduces image cache (120 items / 24 MiB) and disables overscroll effects for smoother performance
|
||||
- Responsive selection bar on artist screen - switches to compact stacked layout on narrow screens (< 430dp) or large text scale (> 1.15x)
|
||||
- Quality picker dialog before downloading individual tracks from artist screen (when "Ask quality before download" is enabled)
|
||||
- Project website with GitHub Pages deployment workflow
|
||||
- Mobile burger menu navigation for all site pages
|
||||
- Go filename template test suite
|
||||
- "Lyrics Provider" extension type - extensions can now provide lyrics (synced or plain text) via `fetchLyrics()` function
|
||||
- Lyrics provider extensions are called before built-in providers, giving extensions highest priority
|
||||
- New `lyrics_provider` manifest type alongside `metadata_provider` and `download_provider`
|
||||
- Shows "Lyrics Provider" capability badge on extension detail page
|
||||
- "Lyrics Providers" settings - configurable provider cascade order and per-provider options
|
||||
- Reorderable provider list: LRCLIB, Musixmatch, Netease, Apple Music, QQ Music
|
||||
- Netease: toggle translated/romanized lyrics appending
|
||||
- Apple Music / QQ Music: multi-person word-by-word speaker tags
|
||||
- Musixmatch: selectable language code for localized lyrics
|
||||
- "Documentation Search" - global search modal on all site pages
|
||||
- Opens with Ctrl+K / Cmd+K / `/` keyboard shortcuts on every page
|
||||
- Search button with bordered pill styling in desktop nav and mobile hamburger menu
|
||||
- On non-docs pages, search results navigate to the docs page at the matching section
|
||||
- Full keyboard navigation: arrow keys, Enter to select, Esc to close
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed ICU plural syntax errors in DE, ES, PT, RU translations - incorrect `=1` clause was causing missing plural forms
|
||||
- Fixed featured-artist regex incorrectly splitting on `&` character (e.g. "Simon & Garfunkel" was being split) - removed `&` from separator pattern
|
||||
- Fixed `{date}` placeholder not working in filename templates - release date was not being passed to the template builder across all providers (Amazon, Qobuz, Tidal, YouTube, extensions)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved Go backend metadata handling - filename builder now supports fallback metadata keys and automatic type conversion for more robust template rendering
|
||||
- Extension providers now pass full metadata set to filename builder (track, disc, year, date, release_date)
|
||||
- Updated translations: added filename advanced tags strings (EN, ID), regenerated all locale dart files
|
||||
- Updated app screenshot assets
|
||||
|
||||
---
|
||||
|
||||
## [3.6.6] - 2026-02-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -97,7 +97,7 @@ The software is provided "as is", without warranty of any kind. The author assum
|
||||
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net)
|
||||
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
|
||||
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
|
||||
- **Lyrics**: [LRCLib](https://lrclib.net)
|
||||
- **Lyrics**: [LRCLib](https://lrclib.net), [Paxsenix](https://lyrics.paxsenix.org) (Apple Music/QQ Music lyrics proxy)
|
||||
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
|
||||
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||
|
||||
|
||||
@@ -1582,6 +1582,32 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getLyricsLRCWithSource" -> {
|
||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
val trackName = call.argument<String>("track_name") ?: ""
|
||||
val artistName = call.argument<String>("artist_name") ?: ""
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
if (filePath.startsWith("content://")) {
|
||||
val tempPath = copyUriToTemp(Uri.parse(filePath))
|
||||
if (tempPath == null) {
|
||||
"""{"lyrics":"","source":"","sync_type":"","instrumental":false}"""
|
||||
} else {
|
||||
try {
|
||||
Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, tempPath, durationMs)
|
||||
} finally {
|
||||
try {
|
||||
File(tempPath).delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs)
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"embedLyricsToFile" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val lyrics = call.argument<String>("lyrics") ?: ""
|
||||
@@ -1756,6 +1782,60 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"setLyricsProviders" -> {
|
||||
val providersJson = call.argument<String>("providers_json") ?: "[]"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.setLyricsProvidersJSON(providersJson)
|
||||
"""{"success":true}"""
|
||||
} catch (e: Exception) {
|
||||
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getLyricsProviders" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.getLyricsProvidersJSON()
|
||||
} catch (e: Exception) {
|
||||
"[]"
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getAvailableLyricsProviders" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.getAvailableLyricsProvidersJSON()
|
||||
} catch (e: Exception) {
|
||||
"[]"
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"setLyricsFetchOptions" -> {
|
||||
val optionsJson = call.argument<String>("options_json") ?: "{}"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.setLyricsFetchOptionsJSON(optionsJson)
|
||||
"""{"success":true}"""
|
||||
} catch (e: Exception) {
|
||||
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getLyricsFetchOptions" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.getLyricsFetchOptionsJSON()
|
||||
} catch (e: Exception) {
|
||||
"{}"
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"reEnrichFile" -> {
|
||||
val requestJson = call.argument<String>("request_json") ?: "{}"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 954 B |
|
Before Width: | Height: | Size: 651 B After Width: | Height: | Size: 647 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1a1a2e</color>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 122 KiB |
@@ -441,6 +441,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
var outputPath string
|
||||
|
||||
@@ -1008,6 +1008,64 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
|
||||
return lrcContent, nil
|
||||
}
|
||||
|
||||
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
if err == nil && lyrics != "" {
|
||||
result := map[string]interface{}{
|
||||
"lyrics": lyrics,
|
||||
"source": "Embedded",
|
||||
"sync_type": "EMBEDDED",
|
||||
"instrumental": false,
|
||||
}
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"lyrics": "",
|
||||
"source": "",
|
||||
"sync_type": "",
|
||||
"instrumental": false,
|
||||
}
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
lrcContent := ""
|
||||
if lyricsData.Instrumental {
|
||||
lrcContent = "[instrumental:true]"
|
||||
} else {
|
||||
lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"lyrics": lrcContent,
|
||||
"source": lyricsData.Source,
|
||||
"sync_type": lyricsData.SyncType,
|
||||
"instrumental": lyricsData.Instrumental,
|
||||
}
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||
err := EmbedLyrics(filePath, lyrics)
|
||||
if err != nil {
|
||||
@@ -1463,7 +1521,7 @@ func errorResponse(msg string) (string, error) {
|
||||
// ==================== YOUTUBE PROVIDER (LOSSY ONLY) ====================
|
||||
|
||||
// DownloadFromYouTube downloads a track from YouTube via Cobalt API
|
||||
// This is a lossy-only provider (Opus 256kbps or MP3 320kbps)
|
||||
// This is a lossy-only provider (Opus/MP3 with configurable bitrate)
|
||||
// It does NOT participate in the lossless fallback chain
|
||||
func DownloadFromYouTube(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
@@ -1599,6 +1657,62 @@ func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int6
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== LYRICS PROVIDER SETTINGS ====================
|
||||
|
||||
// SetLyricsProvidersJSON sets the lyrics provider order from a JSON array of provider IDs.
|
||||
func SetLyricsProvidersJSON(providersJSON string) error {
|
||||
var providers []string
|
||||
if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
SetLyricsProviderOrder(providers)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLyricsProvidersJSON returns the current lyrics provider order as JSON.
|
||||
func GetLyricsProvidersJSON() (string, error) {
|
||||
providers := GetLyricsProviderOrder()
|
||||
jsonBytes, err := json.Marshal(providers)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetAvailableLyricsProvidersJSON returns metadata about all available lyrics providers.
|
||||
func GetAvailableLyricsProvidersJSON() (string, error) {
|
||||
providers := GetAvailableLyricsProviders()
|
||||
jsonBytes, err := json.Marshal(providers)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetLyricsFetchOptionsJSON sets lyrics provider fetch options.
|
||||
func SetLyricsFetchOptionsJSON(optionsJSON string) error {
|
||||
opts := GetLyricsFetchOptions()
|
||||
if strings.TrimSpace(optionsJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
SetLyricsFetchOptions(opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLyricsFetchOptionsJSON returns current lyrics provider fetch options.
|
||||
func GetLyricsFetchOptionsJSON() (string, error) {
|
||||
opts := GetLyricsFetchOptions()
|
||||
jsonBytes, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
|
||||
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
|
||||
// complete metadata from the internet before embedding.
|
||||
|
||||
@@ -713,6 +713,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
Permissions []string `json:"permissions"`
|
||||
HasMetadataProvider bool `json:"has_metadata_provider"`
|
||||
HasDownloadProvider bool `json:"has_download_provider"`
|
||||
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||
@@ -770,6 +771,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
Permissions: permissions,
|
||||
HasMetadataProvider: ext.Manifest.IsMetadataProvider(),
|
||||
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
||||
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
||||
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||
TrackMatching: ext.Manifest.TrackMatching,
|
||||
|
||||
@@ -12,6 +12,7 @@ type ExtensionType string
|
||||
const (
|
||||
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
||||
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||
ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider"
|
||||
)
|
||||
|
||||
type SettingType string
|
||||
@@ -167,10 +168,10 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
|
||||
for _, t := range m.Types {
|
||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider {
|
||||
return &ManifestValidationError{
|
||||
Field: "type",
|
||||
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t),
|
||||
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')", t),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +227,10 @@ func (m *ExtensionManifest) IsDownloadProvider() bool {
|
||||
return m.HasType(ExtensionTypeDownloadProvider)
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) IsLyricsProvider() bool {
|
||||
return m.HasType(ExtensionTypeLyricsProvider)
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
for _, allowed := range m.Permissions.Network {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -1136,8 +1137,13 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"album_artist": req.AlbumArtist,
|
||||
"track": req.TrackNumber,
|
||||
"track_number": req.TrackNumber,
|
||||
"disc": req.DiscNumber,
|
||||
"disc_number": req.DiscNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"release_date": req.ReleaseDate,
|
||||
"isrc": req.ISRC,
|
||||
}
|
||||
|
||||
@@ -1694,3 +1700,140 @@ func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata
|
||||
|
||||
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
||||
}
|
||||
|
||||
// ==================== Lyrics Provider ====================
|
||||
|
||||
// ExtLyricsResult represents lyrics data returned from an extension
|
||||
type ExtLyricsResult struct {
|
||||
Lines []ExtLyricsLine `json:"lines"`
|
||||
SyncType string `json:"syncType"`
|
||||
Instrumental bool `json:"instrumental"`
|
||||
PlainLyrics string `json:"plainLyrics"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
type ExtLyricsLine struct {
|
||||
StartTimeMs int64 `json:"startTimeMs"`
|
||||
Words string `json:"words"`
|
||||
EndTimeMs int64 `json:"endTimeMs"`
|
||||
}
|
||||
|
||||
// FetchLyrics calls the extension's fetchLyrics function
|
||||
func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
||||
if !p.extension.Manifest.IsLyricsProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
|
||||
}
|
||||
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
||||
const trackVar = "__sf_lyrics_track"
|
||||
const artistVar = "__sf_lyrics_artist"
|
||||
const albumVar = "__sf_lyrics_album"
|
||||
const durationVar = "__sf_lyrics_duration"
|
||||
global := p.vm.GlobalObject()
|
||||
_ = global.Set(trackVar, trackName)
|
||||
_ = global.Set(artistVar, artistName)
|
||||
_ = global.Set(albumVar, albumName)
|
||||
_ = global.Set(durationVar, durationSec)
|
||||
defer func() {
|
||||
global.Delete(trackVar)
|
||||
global.Delete(artistVar)
|
||||
global.Delete(albumVar)
|
||||
global.Delete(durationVar)
|
||||
}()
|
||||
|
||||
const script = `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.fetchLyrics === 'function') {
|
||||
return extension.fetchLyrics(__sf_lyrics_track, __sf_lyrics_artist, __sf_lyrics_album, __sf_lyrics_duration);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
if IsTimeoutError(err) {
|
||||
return nil, fmt.Errorf("fetchLyrics timeout: extension took too long to respond")
|
||||
}
|
||||
return nil, fmt.Errorf("fetchLyrics failed: %w", err)
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return nil, fmt.Errorf("fetchLyrics returned null")
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal lyrics result: %w", err)
|
||||
}
|
||||
|
||||
var extResult ExtLyricsResult
|
||||
if err := json.Unmarshal(jsonBytes, &extResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse lyrics result: %w", err)
|
||||
}
|
||||
|
||||
// Convert ExtLyricsResult to LyricsResponse
|
||||
response := &LyricsResponse{
|
||||
SyncType: extResult.SyncType,
|
||||
Instrumental: extResult.Instrumental,
|
||||
PlainLyrics: extResult.PlainLyrics,
|
||||
Provider: extResult.Provider,
|
||||
Source: "Extension: " + p.extension.ID,
|
||||
}
|
||||
|
||||
if response.Provider == "" {
|
||||
response.Provider = p.extension.Manifest.DisplayName
|
||||
}
|
||||
|
||||
for _, line := range extResult.Lines {
|
||||
response.Lines = append(response.Lines, LyricsLine{
|
||||
StartTimeMs: line.StartTimeMs,
|
||||
Words: line.Words,
|
||||
EndTimeMs: line.EndTimeMs,
|
||||
})
|
||||
}
|
||||
|
||||
// If the extension provided plainLyrics but no lines, parse them as unsynced
|
||||
if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental {
|
||||
response.SyncType = "UNSYNCED"
|
||||
for _, line := range strings.Split(response.PlainLyrics, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
response.Lines = append(response.Lines, LyricsLine{
|
||||
StartTimeMs: 0,
|
||||
Words: line,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// GetLyricsProviders returns all enabled extensions that provide lyrics
|
||||
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
|
||||
// Keep a deterministic order so provider selection is stable across runs.
|
||||
sort.Slice(providers, func(i, j int) bool {
|
||||
return providers[i].extension.ID < providers[j].extension.ID
|
||||
})
|
||||
|
||||
return providers
|
||||
}
|
||||
|
||||
@@ -3,28 +3,35 @@ package gobackend
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||
var (
|
||||
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||
multiUnderscore = regexp.MustCompile(`_+`)
|
||||
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
|
||||
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
|
||||
yearPattern = regexp.MustCompile(`\d{4}`)
|
||||
)
|
||||
|
||||
func sanitizeFilename(filename string) string {
|
||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||
|
||||
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
sanitized = strings.Trim(sanitized, ".")
|
||||
|
||||
multiUnderscore := regexp.MustCompile(`_+`)
|
||||
|
||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||
|
||||
|
||||
if len(sanitized) > 200 {
|
||||
sanitized = sanitized[:200]
|
||||
}
|
||||
|
||||
|
||||
if sanitized == "" {
|
||||
sanitized = "untitled"
|
||||
}
|
||||
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
@@ -32,45 +39,120 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
||||
if template == "" {
|
||||
template = "{artist} - {title}"
|
||||
}
|
||||
|
||||
result := template
|
||||
|
||||
placeholders := map[string]string{
|
||||
"{title}": getString(metadata, "title"),
|
||||
"{artist}": getString(metadata, "artist"),
|
||||
"{album}": getString(metadata, "album"),
|
||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||
"{year}": getString(metadata, "year"),
|
||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||
|
||||
result := replaceFormattedNumberPlaceholders(template, metadata)
|
||||
result = replaceDateFormatPlaceholders(result, metadata)
|
||||
|
||||
dateValue := getDateValue(metadata)
|
||||
yearValue := getString(metadata, "year")
|
||||
if yearValue == "" {
|
||||
yearValue = extractYear(dateValue)
|
||||
}
|
||||
|
||||
|
||||
placeholders := map[string]string{
|
||||
"{title}": getString(metadata, "title"),
|
||||
"{artist}": getString(metadata, "artist"),
|
||||
"{album}": getString(metadata, "album"),
|
||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
||||
"{year}": yearValue,
|
||||
"{date}": dateValue,
|
||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||
"{disc_raw}": formatRawNumber(getInt(metadata, "disc")),
|
||||
}
|
||||
|
||||
for placeholder, value := range placeholders {
|
||||
result = strings.ReplaceAll(result, placeholder, value)
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func replaceFormattedNumberPlaceholders(template string, metadata map[string]interface{}) string {
|
||||
return formattedNumberPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
|
||||
parts := formattedNumberPlaceholderExpr.FindStringSubmatch(match)
|
||||
if len(parts) != 3 {
|
||||
return ""
|
||||
}
|
||||
|
||||
number := getInt(metadata, parts[1])
|
||||
width, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatNumberWithWidth(number, width)
|
||||
})
|
||||
}
|
||||
|
||||
func replaceDateFormatPlaceholders(template string, metadata map[string]interface{}) string {
|
||||
return dateFormatPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
|
||||
parts := dateFormatPlaceholderExpr.FindStringSubmatch(match)
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatDateWithPattern(getDateValue(metadata), parts[1])
|
||||
})
|
||||
}
|
||||
|
||||
func getDateValue(metadata map[string]interface{}) string {
|
||||
date := getString(metadata, "date")
|
||||
if date != "" {
|
||||
return date
|
||||
}
|
||||
|
||||
releaseDate := getString(metadata, "release_date")
|
||||
if releaseDate != "" {
|
||||
return releaseDate
|
||||
}
|
||||
|
||||
return getString(metadata, "year")
|
||||
}
|
||||
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(value)
|
||||
case int:
|
||||
return strconv.Itoa(value)
|
||||
case int64:
|
||||
return strconv.FormatInt(value, 10)
|
||||
case float64:
|
||||
return strconv.Itoa(int(value))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getInt(m map[string]interface{}, key string) int {
|
||||
if v, ok := m[key]; ok {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
candidateKeys := []string{key}
|
||||
switch key {
|
||||
case "track":
|
||||
candidateKeys = append(candidateKeys, "track_number")
|
||||
case "disc":
|
||||
candidateKeys = append(candidateKeys, "disc_number")
|
||||
}
|
||||
|
||||
for _, candidate := range candidateKeys {
|
||||
if v, ok := m[candidate]; ok {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(n))
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -88,6 +170,129 @@ func formatDiscNumber(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
func formatRawNumber(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
func formatNumberWithWidth(n int, width int) string {
|
||||
if n <= 0 || width <= 0 {
|
||||
return ""
|
||||
}
|
||||
if width <= 1 {
|
||||
return formatRawNumber(n)
|
||||
}
|
||||
return fmt.Sprintf("%0*d", width, n)
|
||||
}
|
||||
|
||||
func formatDateWithPattern(rawDate string, strftimePattern string) string {
|
||||
if rawDate == "" || strftimePattern == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsedDate, ok := parseMetadataDate(rawDate)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
goLayout := convertStrftimeToGoLayout(strftimePattern)
|
||||
if goLayout == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parsedDate.Format(goLayout)
|
||||
}
|
||||
|
||||
func parseMetadataDate(rawDate string) (time.Time, bool) {
|
||||
clean := strings.TrimSpace(rawDate)
|
||||
if clean == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02",
|
||||
"2006-01",
|
||||
"2006",
|
||||
"2006/01/02",
|
||||
"2006/01",
|
||||
"2006.01.02",
|
||||
"2006.01",
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.Parse(layout, clean)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
|
||||
if len(clean) >= 10 {
|
||||
parsed, err := time.Parse("2006-01-02", clean[:10])
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
|
||||
yearMatch := yearPattern.FindString(clean)
|
||||
if yearMatch == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
year, err := strconv.Atoi(yearMatch)
|
||||
if err != nil || year <= 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
return time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), true
|
||||
}
|
||||
|
||||
func convertStrftimeToGoLayout(pattern string) string {
|
||||
if pattern == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for i := 0; i < len(pattern); i++ {
|
||||
ch := pattern[i]
|
||||
if ch != '%' {
|
||||
builder.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
if i+1 >= len(pattern) {
|
||||
builder.WriteByte('%')
|
||||
break
|
||||
}
|
||||
|
||||
i++
|
||||
switch pattern[i] {
|
||||
case 'Y':
|
||||
builder.WriteString("2006")
|
||||
case 'y':
|
||||
builder.WriteString("06")
|
||||
case 'm':
|
||||
builder.WriteString("01")
|
||||
case 'd':
|
||||
builder.WriteString("02")
|
||||
case 'b':
|
||||
builder.WriteString("Jan")
|
||||
case 'B':
|
||||
builder.WriteString("January")
|
||||
case '%':
|
||||
builder.WriteByte('%')
|
||||
default:
|
||||
builder.WriteByte('%')
|
||||
builder.WriteByte(pattern[i])
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func extractYear(date string) string {
|
||||
if len(date) >= 4 {
|
||||
return date[:4]
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"title": "Song Name",
|
||||
"artist": "Artist Name",
|
||||
"album": "Album Name",
|
||||
"track": 1,
|
||||
"disc": 2,
|
||||
"year": "2025",
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate(
|
||||
"{artist} - {track} - {track_raw} - d{disc} - d{disc_raw} - {title}",
|
||||
metadata,
|
||||
)
|
||||
|
||||
expected := "Artist Name - 01 - 1 - d2 - d2 - Song Name"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_RawPlaceholdersEmptyWhenZero(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"title": "Song Name",
|
||||
"artist": "Artist Name",
|
||||
"track": 0,
|
||||
"disc": 0,
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate("{track_raw}-{disc_raw}-{title}", metadata)
|
||||
expected := "--Song Name"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"track": 3,
|
||||
"disc": 2,
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate("{track:1}-{track:02}-{disc:03}", metadata)
|
||||
expected := "3-03-002"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"artist": "Artist Name",
|
||||
"title": "Song Name",
|
||||
"release_date": "2024-03-09",
|
||||
"track_number": 7,
|
||||
"disc_number": 1,
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate(
|
||||
"{artist} - {track:02} - {title} - {date:%Y-%m-%d} - {year}",
|
||||
metadata,
|
||||
)
|
||||
expected := "Artist Name - 07 - Song Name - 2024-03-09 - 2024"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"artist": "Artist Name",
|
||||
"title": "Song Name",
|
||||
"date": "2019",
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate("{date:%Y}-{date:%m}-{date:%d}", metadata)
|
||||
expected := "2019-01-01"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,12 @@ go 1.25.0
|
||||
toolchain go1.26.0
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
||||
golang.org/x/net v0.50.0
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||
@@ -36,6 +38,8 @@ golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBr
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
|
||||
@@ -20,6 +20,140 @@ const (
|
||||
durationToleranceSec = 10.0
|
||||
)
|
||||
|
||||
// Lyrics provider names (used in settings and cascade ordering)
|
||||
const (
|
||||
LyricsProviderLRCLIB = "lrclib"
|
||||
LyricsProviderNetease = "netease"
|
||||
LyricsProviderMusixmatch = "musixmatch"
|
||||
LyricsProviderAppleMusic = "apple_music"
|
||||
LyricsProviderQQMusic = "qqmusic"
|
||||
)
|
||||
|
||||
// DefaultLyricsProviders is the default cascade order for lyrics fetching.
|
||||
// LRCLIB first (no proxy dependency), then the others.
|
||||
var DefaultLyricsProviders = []string{
|
||||
LyricsProviderLRCLIB,
|
||||
LyricsProviderMusixmatch,
|
||||
LyricsProviderNetease,
|
||||
LyricsProviderAppleMusic,
|
||||
LyricsProviderQQMusic,
|
||||
}
|
||||
|
||||
// Global lyrics provider configuration
|
||||
var (
|
||||
lyricsProvidersMu sync.RWMutex
|
||||
lyricsProviders []string // ordered list of enabled providers
|
||||
)
|
||||
|
||||
// LyricsFetchOptions controls optional provider-specific enhancements.
|
||||
type LyricsFetchOptions struct {
|
||||
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
||||
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
||||
MultiPersonWordByWord bool `json:"multi_person_word_by_word"`
|
||||
MusixmatchLanguage string `json:"musixmatch_language,omitempty"`
|
||||
}
|
||||
|
||||
var defaultLyricsFetchOptions = LyricsFetchOptions{
|
||||
IncludeTranslationNetease: false,
|
||||
IncludeRomanizationNetease: false,
|
||||
MultiPersonWordByWord: true,
|
||||
MusixmatchLanguage: "",
|
||||
}
|
||||
|
||||
var (
|
||||
lyricsFetchOptionsMu sync.RWMutex
|
||||
lyricsFetchOptions = defaultLyricsFetchOptions
|
||||
)
|
||||
|
||||
// SetLyricsProviderOrder sets the ordered list of lyrics providers to try.
|
||||
// Providers not in the list are disabled. An empty list resets to defaults.
|
||||
func SetLyricsProviderOrder(providers []string) {
|
||||
lyricsProvidersMu.Lock()
|
||||
defer lyricsProvidersMu.Unlock()
|
||||
|
||||
if len(providers) == 0 {
|
||||
lyricsProviders = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Validate provider names
|
||||
validNames := map[string]bool{
|
||||
LyricsProviderLRCLIB: true,
|
||||
LyricsProviderNetease: true,
|
||||
LyricsProviderMusixmatch: true,
|
||||
LyricsProviderAppleMusic: true,
|
||||
LyricsProviderQQMusic: true,
|
||||
}
|
||||
|
||||
var valid []string
|
||||
for _, p := range providers {
|
||||
normalized := strings.ToLower(strings.TrimSpace(p))
|
||||
if validNames[normalized] {
|
||||
valid = append(valid, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
lyricsProviders = valid
|
||||
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
||||
}
|
||||
|
||||
// GetLyricsProviderOrder returns the current lyrics provider order.
|
||||
func GetLyricsProviderOrder() []string {
|
||||
lyricsProvidersMu.RLock()
|
||||
defer lyricsProvidersMu.RUnlock()
|
||||
|
||||
if len(lyricsProviders) == 0 {
|
||||
return DefaultLyricsProviders
|
||||
}
|
||||
|
||||
result := make([]string, len(lyricsProviders))
|
||||
copy(result, lyricsProviders)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
|
||||
opts.MusixmatchLanguage = strings.ToLower(strings.TrimSpace(opts.MusixmatchLanguage))
|
||||
opts.MusixmatchLanguage = regexp.MustCompile(`[^a-z0-9\-_]`).ReplaceAllString(opts.MusixmatchLanguage, "")
|
||||
if len(opts.MusixmatchLanguage) > 16 {
|
||||
opts.MusixmatchLanguage = opts.MusixmatchLanguage[:16]
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
|
||||
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||
normalized := normalizeLyricsFetchOptions(opts)
|
||||
|
||||
lyricsFetchOptionsMu.Lock()
|
||||
defer lyricsFetchOptionsMu.Unlock()
|
||||
lyricsFetchOptions = normalized
|
||||
|
||||
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v musixmatch_lang=%q\n",
|
||||
normalized.IncludeTranslationNetease,
|
||||
normalized.IncludeRomanizationNetease,
|
||||
normalized.MultiPersonWordByWord,
|
||||
normalized.MusixmatchLanguage,
|
||||
)
|
||||
}
|
||||
|
||||
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
|
||||
func GetLyricsFetchOptions() LyricsFetchOptions {
|
||||
lyricsFetchOptionsMu.RLock()
|
||||
defer lyricsFetchOptionsMu.RUnlock()
|
||||
return lyricsFetchOptions
|
||||
}
|
||||
|
||||
type lyricsCacheEntry struct {
|
||||
response *LyricsResponse
|
||||
expiresAt time.Time
|
||||
@@ -90,6 +224,15 @@ func (c *lyricsCache) Size() int {
|
||||
return len(c.cache)
|
||||
}
|
||||
|
||||
func (c *lyricsCache) ClearAll() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
cleared := len(c.cache)
|
||||
c.cache = make(map[string]*lyricsCacheEntry)
|
||||
return cleared
|
||||
}
|
||||
|
||||
type LRCLibResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -139,7 +282,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -174,7 +317,7 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -240,68 +383,203 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
|
||||
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
fetchOptions := GetLyricsFetchOptions()
|
||||
|
||||
extManager := GetExtensionManager()
|
||||
var extensionProviders []*ExtensionProviderWrapper
|
||||
if extManager != nil {
|
||||
extensionProviders = extManager.GetLyricsProviders()
|
||||
}
|
||||
|
||||
var cachedNonExtension *LyricsResponse
|
||||
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||
cachedCopy := *cached
|
||||
cachedCopy.Source = cached.Source + " (cached)"
|
||||
isExtensionCache := strings.HasPrefix(cached.Source, "Extension:")
|
||||
if len(extensionProviders) == 0 || isExtensionCache {
|
||||
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||
cachedCopy := *cached
|
||||
cachedCopy.Source = cached.Source + " (cached)"
|
||||
return &cachedCopy, nil
|
||||
}
|
||||
|
||||
// If extension providers are currently enabled, don't let stale built-in cache
|
||||
// mask newly installed/activated extensions.
|
||||
cachedNonExtension = cached
|
||||
GoLog("[Lyrics] Ignoring cached non-extension lyrics because extension providers are available\n")
|
||||
}
|
||||
|
||||
isValidResult := func(l *LyricsResponse) bool {
|
||||
return lyricsHasUsableText(l)
|
||||
}
|
||||
|
||||
// Try extension lyrics providers first
|
||||
if len(extensionProviders) > 0 {
|
||||
for _, provider := range extensionProviders {
|
||||
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
||||
lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID)
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
if err != nil {
|
||||
GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cachedNonExtension != nil {
|
||||
cachedCopy := *cachedNonExtension
|
||||
cachedCopy.Source = cachedNonExtension.Source + " (cached fallback)"
|
||||
GoLog("[Lyrics] Extension providers unavailable for this track, using cached built-in lyrics\n")
|
||||
return &cachedCopy, nil
|
||||
}
|
||||
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
|
||||
isValidResult := func(l *LyricsResponse) bool {
|
||||
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
|
||||
}
|
||||
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
if primaryArtist != artistName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get configured provider order
|
||||
providerOrder := GetLyricsProviderOrder()
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||
|
||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||
|
||||
// Cascade through all configured built-in providers
|
||||
for _, providerName := range providerOrder {
|
||||
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
||||
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
|
||||
switch providerName {
|
||||
case LyricsProviderLRCLIB:
|
||||
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
|
||||
|
||||
case LyricsProviderNetease:
|
||||
neteaseClient := NewNeteaseClient()
|
||||
lyrics, err = neteaseClient.FetchLyrics(
|
||||
trackName,
|
||||
primaryArtist,
|
||||
durationSec,
|
||||
fetchOptions.IncludeTranslationNetease,
|
||||
fetchOptions.IncludeRomanizationNetease,
|
||||
)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = neteaseClient.FetchLyrics(
|
||||
trackName,
|
||||
artistName,
|
||||
durationSec,
|
||||
fetchOptions.IncludeTranslationNetease,
|
||||
fetchOptions.IncludeRomanizationNetease,
|
||||
)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = neteaseClient.FetchLyrics(
|
||||
simplifiedTrack,
|
||||
primaryArtist,
|
||||
durationSec,
|
||||
fetchOptions.IncludeTranslationNetease,
|
||||
fetchOptions.IncludeRomanizationNetease,
|
||||
)
|
||||
}
|
||||
|
||||
case LyricsProviderMusixmatch:
|
||||
musixmatchClient := NewMusixmatchClient()
|
||||
lyrics, err = musixmatchClient.FetchLyrics(
|
||||
trackName,
|
||||
primaryArtist,
|
||||
durationSec,
|
||||
fetchOptions.MusixmatchLanguage,
|
||||
)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = musixmatchClient.FetchLyrics(
|
||||
trackName,
|
||||
artistName,
|
||||
durationSec,
|
||||
fetchOptions.MusixmatchLanguage,
|
||||
)
|
||||
}
|
||||
|
||||
case LyricsProviderAppleMusic:
|
||||
appleClient := NewAppleMusicClient()
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
}
|
||||
|
||||
case LyricsProviderQQMusic:
|
||||
qqClient := NewQQMusicClient()
|
||||
lyrics, err = qqClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
}
|
||||
|
||||
default:
|
||||
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
|
||||
continue
|
||||
}
|
||||
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB (simplified)"
|
||||
GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
query := primaryArtist + " " + trackName
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB Search"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
if simplifiedTrack != trackName {
|
||||
query = primaryArtist + " " + simplifiedTrack
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB Search (simplified)"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
if err != nil {
|
||||
GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("lyrics not found from any source")
|
||||
}
|
||||
|
||||
// tryLRCLIB attempts all LRCLIB search strategies (exact match, simplified, search).
|
||||
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
|
||||
// 1. Exact match with primary artist
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// 2. Exact match with full artist name
|
||||
if primaryArtist != artistName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Simplified track name
|
||||
if simplifiedTrack != trackName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB (simplified)"
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Search by query
|
||||
query := primaryArtist + " " + trackName
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB Search"
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// 5. Search with simplified track name
|
||||
if simplifiedTrack != trackName {
|
||||
query = primaryArtist + " " + simplifiedTrack
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB Search (simplified)"
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("LRCLIB: no lyrics found")
|
||||
}
|
||||
|
||||
func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse {
|
||||
result := &LyricsResponse{
|
||||
Instrumental: resp.Instrumental,
|
||||
@@ -339,10 +617,20 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve Apple/QQ background vocal tags by attaching them to
|
||||
// the previous timed line. This keeps [bg:...] in final exported LRC.
|
||||
if strings.HasPrefix(line, "[bg:") && len(lines) > 0 {
|
||||
lines[len(lines)-1].Words = strings.TrimSpace(lines[len(lines)-1].Words + "\n" + line)
|
||||
continue
|
||||
}
|
||||
|
||||
matches := lrcPattern.FindStringSubmatch(line)
|
||||
if len(matches) == 5 {
|
||||
startMs := lrcTimestampToMs(matches[1], matches[2], matches[3])
|
||||
words := strings.TrimSpace(matches[4])
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
@@ -363,6 +651,63 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
|
||||
return lines
|
||||
}
|
||||
|
||||
func lyricsHasUsableText(lyrics *LyricsResponse) bool {
|
||||
if lyrics == nil {
|
||||
return false
|
||||
}
|
||||
if lyrics.Instrumental {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(lyrics.PlainLyrics) != "" {
|
||||
return true
|
||||
}
|
||||
for _, line := range lyrics.Lines {
|
||||
if strings.TrimSpace(line.Words) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectLyricsErrorPayload extracts human-readable error messages from
|
||||
// JSON payloads returned by lyrics proxies when no lyric is available.
|
||||
func detectLyricsErrorPayload(raw string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
lyricsKeys := []string{"lyrics", "lyric", "lrc", "content", "lines", "syncedLyrics", "unsyncedLyrics"}
|
||||
hasLyricsKey := false
|
||||
for _, key := range lyricsKeys {
|
||||
if _, ok := payload[key]; ok {
|
||||
hasLyricsKey = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
errorKeys := []string{"message", "error", "detail", "reason"}
|
||||
for _, key := range errorKeys {
|
||||
if msg, ok := payload[key].(string); ok {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg != "" && !hasLyricsKey {
|
||||
return msg, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey {
|
||||
return "request unsuccessful", true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
|
||||
min, _ := strconv.ParseInt(minutes, 10, 64)
|
||||
sec, _ := strconv.ParseInt(seconds, 10, 64)
|
||||
@@ -376,12 +721,16 @@ func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
|
||||
}
|
||||
|
||||
func msToLRCTimestamp(ms int64) string {
|
||||
return fmt.Sprintf("[%s]", msToLRCTimestampInline(ms))
|
||||
}
|
||||
|
||||
func msToLRCTimestampInline(ms int64) string {
|
||||
totalSeconds := ms / 1000
|
||||
minutes := totalSeconds / 60
|
||||
seconds := totalSeconds % 60
|
||||
centiseconds := (ms % 1000) / 10
|
||||
|
||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||
return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||
@@ -441,8 +790,18 @@ func simplifyTrackName(name string) string {
|
||||
re := regexp.MustCompile("(?i)" + pattern)
|
||||
result = re.ReplaceAllString(result, "")
|
||||
}
|
||||
result = strings.TrimSpace(result)
|
||||
if result == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
// Add a loose fallback form for provider queries where punctuation
|
||||
// and separators differ (e.g. "/" vs "_" vs spaces).
|
||||
if loose := normalizeLooseTitle(result); loose != "" {
|
||||
return loose
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeArtistName(name string) string {
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppleMusicClient fetches lyrics from Apple Music.
|
||||
// Uses a scraped JWT token for search and a proxy for lyrics.
|
||||
type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Apple Music token manager — singleton with mutex for thread safety
|
||||
type appleTokenManager struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
}
|
||||
|
||||
var globalAppleTokenManager = &appleTokenManager{}
|
||||
|
||||
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.token != "" {
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
// Step 1: Fetch the Apple Music beta page
|
||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Find the index JS file URL
|
||||
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
|
||||
match := indexJsRegex.Find(body)
|
||||
if match == nil {
|
||||
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
|
||||
}
|
||||
|
||||
indexJsURL := "https://beta.music.apple.com" + string(match)
|
||||
|
||||
// Step 3: Fetch the JS file
|
||||
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create JS request: %w", err)
|
||||
}
|
||||
jsReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
jsResp, err := client.Do(jsReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
|
||||
}
|
||||
defer jsResp.Body.Close()
|
||||
|
||||
jsBody, err := io.ReadAll(jsResp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Extract JWT token (starts with eyJh)
|
||||
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
|
||||
tokenMatch := tokenRegex.Find(jsBody)
|
||||
if tokenMatch == nil {
|
||||
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
|
||||
}
|
||||
|
||||
m.token = string(tokenMatch)
|
||||
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
func (m *appleTokenManager) clearToken() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.token = ""
|
||||
}
|
||||
|
||||
// Apple Music API response models
|
||||
type appleMusicSearchResponse struct {
|
||||
Results struct {
|
||||
Songs *struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
} `json:"data"`
|
||||
} `json:"songs"`
|
||||
} `json:"results"`
|
||||
Resources *struct {
|
||||
Songs map[string]struct {
|
||||
Attributes struct {
|
||||
Name string `json:"name"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
URL string `json:"url"`
|
||||
Artwork struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"artwork"`
|
||||
} `json:"attributes"`
|
||||
} `json:"songs"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
|
||||
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
||||
type paxResponse struct {
|
||||
Type string `json:"type"` // "Syllable" or "Line"
|
||||
Content []paxLyrics `json:"content"` // List of lyric lines
|
||||
}
|
||||
|
||||
type paxLyrics struct {
|
||||
Text []paxLyricDetail `json:"text"`
|
||||
Timestamp int `json:"timestamp"`
|
||||
OppositeTurn bool `json:"oppositeTurn"`
|
||||
Background bool `json:"background"`
|
||||
BackgroundText []paxLyricDetail `json:"backgroundText"`
|
||||
EndTime int `json:"endtime"`
|
||||
}
|
||||
|
||||
type paxLyricDetail struct {
|
||||
Text string `json:"text"`
|
||||
Part bool `json:"part"`
|
||||
Timestamp *int `json:"timestamp"`
|
||||
EndTime *int `json:"endtime"`
|
||||
}
|
||||
|
||||
func NewAppleMusicClient() *AppleMusicClient {
|
||||
return &AppleMusicClient{
|
||||
httpClient: NewMetadataHTTPClient(20 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Apple Music and returns its ID.
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
token, err := globalAppleTokenManager.getToken(c.httpClient)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music token error: %w", err)
|
||||
}
|
||||
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf(
|
||||
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
|
||||
encodedQuery,
|
||||
)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Origin", "https://music.apple.com")
|
||||
req.Header.Set("Referer", "https://music.apple.com/")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
globalAppleTokenManager.clearToken()
|
||||
return "", fmt.Errorf("apple music token expired")
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp appleMusicSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
|
||||
return "", fmt.Errorf("no songs found on apple music")
|
||||
}
|
||||
|
||||
return searchResp.Results.Songs.Data[0].ID, nil
|
||||
}
|
||||
|
||||
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
|
||||
func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||
lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID)
|
||||
|
||||
req, err := http.NewRequest("GET", lyricsURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music lyrics fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music lyrics proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read lyrics response: %w", err)
|
||||
}
|
||||
|
||||
bodyStr := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyStr == "" {
|
||||
return "", fmt.Errorf("empty lyrics response from apple music")
|
||||
}
|
||||
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
// formatPaxLyricsToLRC converts a pax proxy response to standard LRC format.
|
||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||
// Try to parse as PaxResponse first
|
||||
var paxResp paxResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
// Try to parse as a direct list of PaxLyrics
|
||||
var directLyrics []paxLyrics
|
||||
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
||||
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to parse pax lyrics response")
|
||||
}
|
||||
|
||||
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
|
||||
lastStart := ""
|
||||
|
||||
for _, syllable := range details {
|
||||
if syllable.Timestamp != nil {
|
||||
start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp)))
|
||||
if start != lastStart {
|
||||
builder.WriteString(start)
|
||||
lastStart = start
|
||||
}
|
||||
}
|
||||
|
||||
builder.WriteString(syllable.Text)
|
||||
if !syllable.Part {
|
||||
builder.WriteString(" ")
|
||||
}
|
||||
|
||||
if syllable.EndTime != nil {
|
||||
builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
for i, line := range content {
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
timestamp := msToLRCTimestamp(int64(line.Timestamp))
|
||||
|
||||
if strings.EqualFold(lyricsType, "Syllable") {
|
||||
sb.WriteString(timestamp)
|
||||
if multiPersonWordByWord {
|
||||
if line.OppositeTurn {
|
||||
sb.WriteString("v2:")
|
||||
} else {
|
||||
sb.WriteString("v1:")
|
||||
}
|
||||
}
|
||||
|
||||
appendPaxLyricDetail(&sb, line.Text)
|
||||
|
||||
if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 {
|
||||
sb.WriteString("\n[bg:")
|
||||
appendPaxLyricDetail(&sb, line.BackgroundText)
|
||||
sb.WriteString("]")
|
||||
}
|
||||
} else {
|
||||
if len(line.Text) > 0 {
|
||||
sb.WriteString(timestamp)
|
||||
sb.WriteString(line.Text[0].Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// FetchLyrics searches Apple Music and returns parsed LyricsResponse.
|
||||
func (c *AppleMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName string,
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawLyrics, err := c.FetchLyricsByID(songID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
|
||||
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try to parse as pax format (word-by-word or line)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
// If pax parsing fails, try to parse as direct LRC text
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Apple Music",
|
||||
Source: "Apple Music",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fall back to plain text if no timestamps found
|
||||
plainLines := strings.Split(lrcText, "\n")
|
||||
var resultLines []LyricsLine
|
||||
for _, line := range plainLines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
resultLines = append(resultLines, LyricsLine{
|
||||
StartTimeMs: 0,
|
||||
Words: trimmed,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(resultLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: resultLines,
|
||||
SyncType: "UNSYNCED",
|
||||
Provider: "Apple Music",
|
||||
Source: "Apple Music",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on apple music")
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MusixmatchClient fetches lyrics from Musixmatch via a proxy server.
|
||||
// The proxy handles Musixmatch authentication internally.
|
||||
type MusixmatchClient struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// Musixmatch proxy response models
|
||||
type musixmatchSearchResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Artwork string `json:"artwork"`
|
||||
ReleaseDate string `json:"releaseDate"`
|
||||
Duration int `json:"duration"`
|
||||
URL string `json:"url"`
|
||||
AlbumID int64 `json:"albumId"`
|
||||
HasSyncedLyrics bool `json:"hasSyncedLyrics"`
|
||||
HasUnsyncedLyrics bool `json:"hasUnsyncedLyrics"`
|
||||
AvailableLanguages []string `json:"availableLanguages"`
|
||||
OriginalLanguage string `json:"originalLanguage"`
|
||||
SyncedLyrics *musixmatchLyricsResponse `json:"syncedLyrics"`
|
||||
UnsyncedLyrics *musixmatchLyricsResponse `json:"unsyncedLyrics"`
|
||||
}
|
||||
|
||||
type musixmatchLyricsResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Duration int `json:"duration"`
|
||||
Language string `json:"language"`
|
||||
UpdatedTime string `json:"updatedTime"`
|
||||
Lyrics string `json:"lyrics"`
|
||||
}
|
||||
|
||||
func NewMusixmatchClient() *MusixmatchClient {
|
||||
return &MusixmatchClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
baseURL: "http://158.180.60.95",
|
||||
}
|
||||
}
|
||||
|
||||
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
|
||||
// The Musixmatch proxy returns both search result and lyrics in a single response.
|
||||
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
|
||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||
return nil, fmt.Errorf("empty track or artist name")
|
||||
}
|
||||
|
||||
encodedArtist := url.QueryEscape(artistName)
|
||||
encodedTrack := url.QueryEscape(trackName)
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
|
||||
lang := strings.ToLower(strings.TrimSpace(language))
|
||||
if songID <= 0 || lang == "" {
|
||||
return nil, fmt.Errorf("invalid song id or language")
|
||||
}
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
||||
}
|
||||
|
||||
// Prefer synced lyrics for selected language
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to unsynced lyrics for selected language
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
var lines []LyricsLine
|
||||
for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: 0,
|
||||
Words: trimmed,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
||||
}
|
||||
|
||||
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
||||
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||
result, err := c.searchAndGetLyrics(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
||||
if localizedErr == nil {
|
||||
return localized, nil
|
||||
}
|
||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||
}
|
||||
|
||||
// Prefer synced lyrics
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to unsynced lyrics
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
var lines []LyricsLine
|
||||
for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: 0,
|
||||
Words: trimmed,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
|
||||
// This is a direct public API — no proxy dependency.
|
||||
type NeteaseClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Netease API response models
|
||||
type neteaseSearchResponse struct {
|
||||
Result struct {
|
||||
Songs []struct {
|
||||
Name string `json:"name"`
|
||||
ID int64 `json:"id"`
|
||||
Artists []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"artists"`
|
||||
} `json:"songs"`
|
||||
SongCount int `json:"songCount"`
|
||||
} `json:"result"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
type neteaseLyricsResponse struct {
|
||||
LRC *neteaseLyricField `json:"lrc"`
|
||||
TLyric *neteaseLyricField `json:"tlyric"`
|
||||
RomaLRC *neteaseLyricField `json:"romalrc"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
type neteaseLyricField struct {
|
||||
Lyric string `json:"lyric"`
|
||||
}
|
||||
|
||||
var neteaseHeaders = map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Cache-Control": "max-age=0",
|
||||
}
|
||||
|
||||
func NewNeteaseClient() *NeteaseClient {
|
||||
return &NeteaseClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Netease and returns the song ID.
|
||||
func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return 0, fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
searchURL := "http://music.163.com/api/search/pc"
|
||||
params := url.Values{}
|
||||
params.Set("s", query)
|
||||
params.Set("type", "1")
|
||||
params.Set("limit", "1")
|
||||
params.Set("offset", "0")
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range neteaseHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("netease search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return 0, fmt.Errorf("netease search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp neteaseSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return 0, fmt.Errorf("failed to decode netease search: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 {
|
||||
return 0, fmt.Errorf("no songs found on netease")
|
||||
}
|
||||
|
||||
return searchResp.Result.Songs[0].ID, nil
|
||||
}
|
||||
|
||||
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
|
||||
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
||||
lyricsURL := "http://music.163.com/api/song/lyric"
|
||||
params := url.Values{}
|
||||
params.Set("id", fmt.Sprintf("%d", songID))
|
||||
params.Set("lv", "1")
|
||||
params.Set("tv", "1")
|
||||
params.Set("rv", "1")
|
||||
|
||||
fullURL := lyricsURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range neteaseHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("netease lyrics fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("netease lyrics returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var lyricsResp neteaseLyricsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&lyricsResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode netease lyrics: %w", err)
|
||||
}
|
||||
|
||||
if lyricsResp.LRC == nil || strings.TrimSpace(lyricsResp.LRC.Lyric) == "" {
|
||||
return "", fmt.Errorf("no lyrics available on netease")
|
||||
}
|
||||
|
||||
lyric := lyricsResp.LRC.Lyric
|
||||
|
||||
if includeTranslation && lyricsResp.TLyric != nil && strings.TrimSpace(lyricsResp.TLyric.Lyric) != "" {
|
||||
lyric += "\n\n" + lyricsResp.TLyric.Lyric
|
||||
}
|
||||
|
||||
if includeRomanization && lyricsResp.RomaLRC != nil && strings.TrimSpace(lyricsResp.RomaLRC.Lyric) != "" {
|
||||
lyric += "\n\n" + lyricsResp.RomaLRC.Lyric
|
||||
}
|
||||
|
||||
return lyric, nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches for a track and returns parsed LyricsResponse.
|
||||
func (c *NeteaseClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName string,
|
||||
durationSec float64,
|
||||
includeTranslation,
|
||||
includeRomanization bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lrcText, err := c.FetchLyricsByID(songID, includeTranslation, includeRomanization)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the LRC text into LyricsResponse
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) == 0 {
|
||||
// May be plain text lyrics without timestamps
|
||||
plainLines := strings.Split(lrcText, "\n")
|
||||
for _, line := range plainLines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: 0,
|
||||
Words: trimmed,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("netease returned empty lyrics")
|
||||
}
|
||||
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
Provider: "Netease",
|
||||
Source: "Netease",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Netease",
|
||||
Source: "Netease",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QQMusicClient fetches lyrics from QQ Music.
|
||||
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
|
||||
type QQMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// QQ Music search response models
|
||||
type qqMusicSearchResponse struct {
|
||||
Data struct {
|
||||
Song struct {
|
||||
List []struct {
|
||||
Title string `json:"title"`
|
||||
Singer []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"singer"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"list"`
|
||||
} `json:"song"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// QQ Music lyrics request payload for paxsenix proxy
|
||||
type qqLyricsPayload struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func NewQQMusicClient() *QQMusicClient {
|
||||
return &QQMusicClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
||||
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
|
||||
params := url.Values{}
|
||||
params.Set("format", "json")
|
||||
params.Set("inCharset", "utf8")
|
||||
params.Set("outCharset", "utf8")
|
||||
params.Set("platform", "yqq.json")
|
||||
params.Set("new_json", "1")
|
||||
params.Set("w", query)
|
||||
|
||||
fullURL := searchURL + "?" + 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("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqmusic search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp qqMusicSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
|
||||
}
|
||||
|
||||
if len(searchResp.Data.Song.List) == 0 {
|
||||
return nil, fmt.Errorf("no songs found on qqmusic")
|
||||
}
|
||||
|
||||
song := searchResp.Data.Song.List[0]
|
||||
|
||||
var artists []string
|
||||
for _, singer := range song.Singer {
|
||||
artists = append(artists, singer.Name)
|
||||
}
|
||||
|
||||
return &qqLyricsPayload{
|
||||
Artist: artists,
|
||||
Album: song.Album.Name,
|
||||
ID: song.ID,
|
||||
Title: song.Title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
|
||||
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
|
||||
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qqmusic lyrics fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("qqmusic lyrics proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read lyrics response: %w", err)
|
||||
}
|
||||
|
||||
bodyStr := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyStr == "" {
|
||||
return "", fmt.Errorf("empty lyrics response from qqmusic")
|
||||
}
|
||||
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||
func (c *QQMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName string,
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
payload, err := c.searchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
|
||||
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try to parse as pax format (word-by-word or line)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
// If pax parsing fails, try to use as direct LRC text
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "QQ Music",
|
||||
Source: "QQ Music",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fall back to plain text
|
||||
plainLines := strings.Split(lrcText, "\n")
|
||||
var resultLines []LyricsLine
|
||||
for _, line := range plainLines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
resultLines = append(resultLines, LyricsLine{
|
||||
StartTimeMs: 0,
|
||||
Words: trimmed,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(resultLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: resultLines,
|
||||
SyncType: "UNSYNCED",
|
||||
Provider: "QQ Music",
|
||||
Source: "QQ Music",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on qqmusic")
|
||||
}
|
||||
@@ -174,6 +174,26 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
looseExpected := normalizeLooseTitle(normExpected)
|
||||
looseFound := normalizeLooseTitle(normFound)
|
||||
if looseExpected != "" && looseFound != "" {
|
||||
if looseExpected == looseFound {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Some tracks are symbol/emoji-heavy and providers can return textual
|
||||
// aliases. If artist/duration already matched upstream, avoid false rejects.
|
||||
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
|
||||
strings.TrimSpace(expectedTitle) != "" &&
|
||||
strings.TrimSpace(foundTitle) != "" {
|
||||
GoLog("[Qobuz] Symbol-heavy title detected, relaxing match: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
||||
return true
|
||||
}
|
||||
|
||||
expectedLatin := qobuzIsLatinScript(expectedTitle)
|
||||
foundLatin := qobuzIsLatinScript(foundTitle)
|
||||
if expectedLatin != foundLatin {
|
||||
@@ -1180,6 +1200,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
var outputPath string
|
||||
|
||||
@@ -1289,6 +1289,26 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
looseExpected := normalizeLooseTitle(normExpected)
|
||||
looseFound := normalizeLooseTitle(normFound)
|
||||
if looseExpected != "" && looseFound != "" {
|
||||
if looseExpected == looseFound {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Some tracks are symbol/emoji-heavy and providers can return textual
|
||||
// aliases. If artist/duration already matched upstream, avoid false rejects.
|
||||
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
|
||||
strings.TrimSpace(expectedTitle) != "" &&
|
||||
strings.TrimSpace(foundTitle) != "" {
|
||||
GoLog("[Tidal] Symbol-heavy title detected, relaxing match: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
||||
return true
|
||||
}
|
||||
|
||||
expectedLatin := isLatinScript(expectedTitle)
|
||||
foundLatin := isLatinScript(foundTitle)
|
||||
if expectedLatin != foundLatin {
|
||||
@@ -1609,6 +1629,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// normalizeLooseTitle collapses separators/punctuation so titles like
|
||||
// "Doctor / Cops" and "Doctor _ Cops" can still match.
|
||||
func normalizeLooseTitle(title string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(title))
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(trimmed))
|
||||
|
||||
for _, r := range trimmed {
|
||||
switch {
|
||||
case unicode.IsLetter(r), unicode.IsNumber(r):
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r):
|
||||
b.WriteByte(' ')
|
||||
// Treat common separators as spaces.
|
||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||
b.WriteByte(' ')
|
||||
default:
|
||||
// Drop other punctuation/symbols (including emoji) for loose matching.
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
func hasAlphaNumericRunes(value string) bool {
|
||||
for _, r := range value {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeLooseTitle_Separators(t *testing.T) {
|
||||
got := normalizeLooseTitle("Doctor / Cops")
|
||||
if got != "doctor cops" {
|
||||
t.Fatalf("expected doctor cops, got %q", got)
|
||||
}
|
||||
|
||||
got = normalizeLooseTitle("Doctor _ Cops")
|
||||
if got != "doctor cops" {
|
||||
t.Fatalf("expected doctor cops, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) {
|
||||
got := normalizeLooseTitle("Music Of The Spheres 🌎✨")
|
||||
if got != "music of the spheres" {
|
||||
t.Fatalf("expected music of the spheres, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitlesMatch_SeparatorVariants(t *testing.T) {
|
||||
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
||||
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzTitlesMatch_SeparatorVariants(t *testing.T) {
|
||||
if !qobuzTitlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
||||
t.Fatal("expected qobuzTitlesMatch to accept / vs _ variant")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -20,6 +21,8 @@ type YouTubeDownloader struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
const spotubeBaseURL = "https://spotubedl.com"
|
||||
|
||||
var (
|
||||
globalYouTubeDownloader *YouTubeDownloader
|
||||
youtubeDownloaderOnce sync.Once
|
||||
@@ -29,9 +32,17 @@ type YouTubeQuality string
|
||||
|
||||
const (
|
||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
||||
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
||||
YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
|
||||
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeOpusSupportedBitrates = []int{128, 256}
|
||||
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
||||
)
|
||||
|
||||
type CobaltRequest struct {
|
||||
URL string `json:"url"`
|
||||
AudioBitrate string `json:"audioBitrate,omitempty"`
|
||||
@@ -79,6 +90,77 @@ func NewYouTubeDownloader() *YouTubeDownloader {
|
||||
return globalYouTubeDownloader
|
||||
}
|
||||
|
||||
func extractBitrateFromQuality(raw string, defaultBitrate int) int {
|
||||
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return (r < '0' || r > '9')
|
||||
})
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := parts[i]
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if parsed, err := strconv.Atoi(part); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultBitrate
|
||||
}
|
||||
|
||||
func nearestSupportedBitrate(value int, supported []int) int {
|
||||
nearest := supported[0]
|
||||
nearestDistance := absInt(value - nearest)
|
||||
|
||||
for _, option := range supported[1:] {
|
||||
distance := absInt(value - option)
|
||||
// On tie prefer higher quality.
|
||||
if distance < nearestDistance || (distance == nearestDistance && option > nearest) {
|
||||
nearest = option
|
||||
nearestDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
return nearest
|
||||
}
|
||||
|
||||
func absInt(value int) int {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) {
|
||||
normalizedRaw := strings.ToLower(strings.TrimSpace(raw))
|
||||
|
||||
if strings.HasPrefix(normalizedRaw, "opus") {
|
||||
parsed := extractBitrateFromQuality(normalizedRaw, 256)
|
||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates)
|
||||
return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(normalizedRaw, "mp3") {
|
||||
parsed := extractBitrateFromQuality(normalizedRaw, 320)
|
||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates)
|
||||
return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate))
|
||||
}
|
||||
|
||||
// Backward compatibility for legacy symbolic values.
|
||||
switch normalizedRaw {
|
||||
case "opus_256", "opus256", "opus":
|
||||
return "opus", 256, YouTubeQualityOpus256
|
||||
case "opus_128", "opus128":
|
||||
return "opus", 128, YouTubeQualityOpus128
|
||||
case "mp3_320", "mp3320", "mp3", "":
|
||||
return "mp3", 320, YouTubeQualityMP3320
|
||||
case "mp3_256", "mp3256":
|
||||
return "mp3", 256, YouTubeQualityMP3256
|
||||
case "mp3_128", "mp3128":
|
||||
return "mp3", 128, YouTubeQualityMP3128
|
||||
default:
|
||||
return "mp3", 320, YouTubeQualityMP3320
|
||||
}
|
||||
}
|
||||
|
||||
// SearchYouTube returns a YouTube Music search URL for the given track
|
||||
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||
@@ -95,22 +177,11 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
|
||||
var audioFormat string
|
||||
var audioBitrate string
|
||||
|
||||
switch quality {
|
||||
case YouTubeQualityOpus256:
|
||||
audioFormat = "opus"
|
||||
audioBitrate = "256"
|
||||
case YouTubeQualityMP3320:
|
||||
audioFormat = "mp3"
|
||||
audioBitrate = "320"
|
||||
default:
|
||||
audioFormat = "mp3"
|
||||
audioBitrate = "320"
|
||||
}
|
||||
audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
|
||||
audioBitrate := strconv.Itoa(bitrate)
|
||||
|
||||
// Try SpotubeDL first (primary)
|
||||
var spotubeErr error
|
||||
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
||||
if extractErr == nil {
|
||||
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
||||
@@ -120,6 +191,7 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
spotubeErr = err
|
||||
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
||||
} else {
|
||||
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
||||
@@ -132,6 +204,9 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
|
||||
|
||||
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
||||
if err != nil {
|
||||
if spotubeErr != nil {
|
||||
return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err)
|
||||
}
|
||||
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
|
||||
}
|
||||
|
||||
@@ -201,11 +276,34 @@ func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitr
|
||||
}
|
||||
|
||||
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
|
||||
// Note: engine v2 currently serves MP3-oriented outputs, so we only use v2 for MP3 requests.
|
||||
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||
apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s",
|
||||
videoID, audioFormat, audioBitrate)
|
||||
engines := []string{"v1"}
|
||||
if strings.EqualFold(audioFormat, "mp3") {
|
||||
engines = append(engines, "v2")
|
||||
}
|
||||
var lastErr error
|
||||
|
||||
GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL)
|
||||
for _, engine := range engines {
|
||||
resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = err
|
||||
GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err)
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no SpotubeDL engine available")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s",
|
||||
spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate))
|
||||
|
||||
GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL)
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
@@ -225,27 +323,60 @@ func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode)
|
||||
GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body))
|
||||
return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
||||
}
|
||||
|
||||
if result.URL == "" {
|
||||
return nil, fmt.Errorf("no download URL from spotubedl")
|
||||
downloadURL := strings.TrimSpace(result.URL)
|
||||
if downloadURL == "" {
|
||||
if result.Error != "" {
|
||||
return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error)
|
||||
}
|
||||
if result.Message != "" {
|
||||
return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from SpotubeDL\n")
|
||||
if strings.HasPrefix(downloadURL, "/") {
|
||||
downloadURL = spotubeBaseURL + downloadURL
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
|
||||
return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL)
|
||||
}
|
||||
|
||||
filename := strings.TrimSpace(result.Filename)
|
||||
if filename == "" {
|
||||
if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil {
|
||||
if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" {
|
||||
if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil {
|
||||
filename = decodedFilename
|
||||
} else {
|
||||
filename = queryFilename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine)
|
||||
return &CobaltResponse{
|
||||
Status: "tunnel",
|
||||
URL: result.URL,
|
||||
Status: "tunnel",
|
||||
URL: downloadURL,
|
||||
Filename: filename,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -411,15 +542,7 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
||||
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||
downloader := NewYouTubeDownloader()
|
||||
|
||||
var quality YouTubeQuality
|
||||
switch strings.ToLower(req.Quality) {
|
||||
case "opus_256", "opus256", "opus":
|
||||
quality = YouTubeQualityOpus256
|
||||
case "mp3_320", "mp3320", "mp3":
|
||||
quality = YouTubeQualityMP3320
|
||||
default:
|
||||
quality = YouTubeQualityMP3320 // Default to MP3 320kbps
|
||||
}
|
||||
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
|
||||
|
||||
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
|
||||
var youtubeURL string
|
||||
@@ -480,18 +603,23 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
var ext string
|
||||
var format string
|
||||
var bitrate int
|
||||
switch quality {
|
||||
case YouTubeQualityOpus256:
|
||||
ext := ".mp3"
|
||||
if format == "opus" {
|
||||
ext = ".opus"
|
||||
format = "opus"
|
||||
bitrate = 256
|
||||
case YouTubeQualityMP3320:
|
||||
ext = ".mp3"
|
||||
format = "mp3"
|
||||
bitrate = 320
|
||||
}
|
||||
|
||||
// Some SpotubeDL engines may return a different output container than requested.
|
||||
// Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
|
||||
if cobaltResp != nil && cobaltResp.Filename != "" {
|
||||
lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename))
|
||||
switch {
|
||||
case strings.HasSuffix(lowerName, ".mp3"):
|
||||
ext = ".mp3"
|
||||
format = "mp3"
|
||||
case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"):
|
||||
ext = ".opus"
|
||||
format = "opus"
|
||||
}
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||
@@ -500,6 +628,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ext
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) {
|
||||
format, bitrate, normalized := parseYouTubeQualityInput("opus_160")
|
||||
if format != "opus" {
|
||||
t.Fatalf("expected opus format, got %s", format)
|
||||
}
|
||||
if bitrate != 128 {
|
||||
t.Fatalf("expected 128 bitrate, got %d", bitrate)
|
||||
}
|
||||
if normalized != YouTubeQualityOpus128 {
|
||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) {
|
||||
format, bitrate, normalized := parseYouTubeQualityInput("mp3_192")
|
||||
if format != "mp3" {
|
||||
t.Fatalf("expected mp3 format, got %s", format)
|
||||
}
|
||||
if bitrate != 256 {
|
||||
t.Fatalf("expected 256 bitrate, got %d", bitrate)
|
||||
}
|
||||
if normalized != YouTubeQualityMP3256 {
|
||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
||||
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
||||
if opusBitrate != 256 {
|
||||
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
|
||||
}
|
||||
|
||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
||||
if mp3Bitrate != 128 {
|
||||
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -191,6 +191,17 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getLyricsLRCWithSource":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let spotifyId = args["spotify_id"] as! String
|
||||
let trackName = args["track_name"] as! String
|
||||
let artistName = args["artist_name"] as! String
|
||||
let filePath = args["file_path"] as? String ?? ""
|
||||
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||
let response = GobackendGetLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "embedLyricsToFile":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -783,6 +794,36 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Lyrics Provider Settings
|
||||
case "setLyricsProviders":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let providersJson = args["providers_json"] as? String ?? "[]"
|
||||
GobackendSetLyricsProvidersJSON(providersJson, &error)
|
||||
if let error = error { throw error }
|
||||
return "{\"success\":true}"
|
||||
|
||||
case "getLyricsProviders":
|
||||
let response = GobackendGetLyricsProvidersJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getAvailableLyricsProviders":
|
||||
let response = GobackendGetAvailableLyricsProvidersJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setLyricsFetchOptions":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let optionsJson = args["options_json"] as? String ?? "{}"
|
||||
GobackendSetLyricsFetchOptionsJSON(optionsJson, &error)
|
||||
if let error = error { throw error }
|
||||
return "{\"success\":true}"
|
||||
|
||||
case "getLyricsFetchOptions":
|
||||
let response = GobackendGetLyricsFetchOptionsJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
default:
|
||||
throw NSError(
|
||||
domain: "SpotiFLAC",
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 429 B |
|
Before Width: | Height: | Size: 576 B After Width: | Height: | Size: 905 B |
|
Before Width: | Height: | Size: 744 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 419 B After Width: | Height: | Size: 624 B |
|
Before Width: | Height: | Size: 789 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 576 B After Width: | Height: | Size: 905 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 717 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 752 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -10,9 +10,13 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
|
||||
final _routerProvider = Provider<GoRouter>((ref) {
|
||||
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||
final hasCompletedTutorial = ref.watch(settingsProvider.select((s) => s.hasCompletedTutorial));
|
||||
|
||||
final isFirstLaunch = ref.watch(
|
||||
settingsProvider.select((s) => s.isFirstLaunch),
|
||||
);
|
||||
final hasCompletedTutorial = ref.watch(
|
||||
settingsProvider.select((s) => s.hasCompletedTutorial),
|
||||
);
|
||||
|
||||
// Determine initial location based on app state
|
||||
String initialLocation;
|
||||
if (isFirstLaunch) {
|
||||
@@ -22,18 +26,12 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
||||
} else {
|
||||
initialLocation = '/';
|
||||
}
|
||||
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: initialLocation,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const MainShell(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/setup',
|
||||
builder: (context, state) => const SetupScreen(),
|
||||
),
|
||||
GoRoute(path: '/', builder: (context, state) => const MainShell()),
|
||||
GoRoute(path: '/setup', builder: (context, state) => const SetupScreen()),
|
||||
GoRoute(
|
||||
path: '/tutorial',
|
||||
builder: (context, state) => const TutorialScreen(),
|
||||
@@ -43,13 +41,18 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
||||
});
|
||||
|
||||
class SpotiFLACApp extends ConsumerWidget {
|
||||
const SpotiFLACApp({super.key});
|
||||
final bool disableOverscrollEffects;
|
||||
|
||||
const SpotiFLACApp({super.key, this.disableOverscrollEffects = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(_routerProvider);
|
||||
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
||||
|
||||
final scrollBehavior = disableOverscrollEffects
|
||||
? const MaterialScrollBehavior().copyWith(overscroll: false)
|
||||
: null;
|
||||
|
||||
Locale? locale;
|
||||
if (localeString != 'system') {
|
||||
if (localeString.contains('_')) {
|
||||
@@ -59,7 +62,7 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
locale = Locale(localeString);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return DynamicColorWrapper(
|
||||
builder: (lightTheme, darkTheme, themeMode) {
|
||||
return MaterialApp.router(
|
||||
@@ -68,6 +71,7 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme,
|
||||
themeMode: themeMode,
|
||||
scrollBehavior: scrollBehavior,
|
||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||
themeAnimationCurve: Curves.easeInOut,
|
||||
routerConfig: router,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.6.6';
|
||||
static const String buildNumber = '80';
|
||||
static const String version = '3.6.9';
|
||||
static const String buildNumber = '82';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -2152,6 +2152,18 @@ abstract class AppLocalizations {
|
||||
/// **'{artist} - {title}'**
|
||||
String filenameHint(Object artist, Object title);
|
||||
|
||||
/// Toggle label for showing advanced filename tags
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show advanced tags'**
|
||||
String get filenameShowAdvancedTags;
|
||||
|
||||
/// Description for advanced filename tag toggle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enable formatted tags for track padding and date patterns'**
|
||||
String get filenameShowAdvancedTagsDescription;
|
||||
|
||||
/// Setting title - folder structure
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3496,6 +3508,42 @@ abstract class AppLocalizations {
|
||||
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
|
||||
String get youtubeQualityNote;
|
||||
|
||||
/// Title for YouTube Opus bitrate setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'YouTube Opus Bitrate'**
|
||||
String get youtubeOpusBitrateTitle;
|
||||
|
||||
/// Title for YouTube MP3 bitrate setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'YouTube MP3 Bitrate'**
|
||||
String get youtubeMp3BitrateTitle;
|
||||
|
||||
/// Subtitle showing current bitrate and valid range
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{bitrate}kbps ({min}-{max})'**
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max);
|
||||
|
||||
/// Helper text for manual YouTube bitrate input
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter custom bitrate ({min}-{max} kbps)'**
|
||||
String youtubeBitrateInputHelp(int min, int max);
|
||||
|
||||
/// Label for YouTube bitrate input field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bitrate (kbps)'**
|
||||
String get youtubeBitrateFieldLabel;
|
||||
|
||||
/// Validation error for invalid YouTube bitrate input
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bitrate must be between {min} and {max} kbps'**
|
||||
String youtubeBitrateValidationError(int min, int max);
|
||||
|
||||
/// Setting - show quality picker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -1182,6 +1182,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Folder Organization';
|
||||
|
||||
@@ -1916,6 +1923,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
|
||||
@@ -13,62 +13,62 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get appDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'Téléchargez des pistes Spotify en qualité sans perte de Tidal, Qobuz et Amazon Music.';
|
||||
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
String get navHome => 'Accueil';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
String get navLibrary => 'Bibliothèques';
|
||||
|
||||
@override
|
||||
String get navHistory => 'History';
|
||||
String get navHistory => 'Historique';
|
||||
|
||||
@override
|
||||
String get navSettings => 'Settings';
|
||||
String get navSettings => 'Paramètres';
|
||||
|
||||
@override
|
||||
String get navStore => 'Store';
|
||||
String get navStore => 'Magasin';
|
||||
|
||||
@override
|
||||
String get homeTitle => 'Home';
|
||||
String get homeTitle => 'Accueil';
|
||||
|
||||
@override
|
||||
String get homeSearchHint => 'Paste Spotify URL or search...';
|
||||
String get homeSearchHint => 'Coller l\'URL Spotify ou rechercher...';
|
||||
|
||||
@override
|
||||
String homeSearchHintExtension(String extensionName) {
|
||||
return 'Search with $extensionName...';
|
||||
return 'Rechercher avec $extensionName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
||||
String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom';
|
||||
|
||||
@override
|
||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
||||
String get homeSupports => 'Supports: Piste, Album, Playlist, Artiste URLs';
|
||||
|
||||
@override
|
||||
String get homeRecent => 'Recent';
|
||||
String get homeRecent => 'Récent';
|
||||
|
||||
@override
|
||||
String get historyTitle => 'History';
|
||||
String get historyTitle => 'Historique';
|
||||
|
||||
@override
|
||||
String historyDownloading(int count) {
|
||||
return 'Downloading ($count)';
|
||||
return 'Téléchargement ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get historyDownloaded => 'Downloaded';
|
||||
String get historyDownloaded => 'Téléchargé';
|
||||
|
||||
@override
|
||||
String get historyFilterAll => 'All';
|
||||
String get historyFilterAll => 'Tous';
|
||||
|
||||
@override
|
||||
String get historyFilterAlbums => 'Albums';
|
||||
|
||||
@override
|
||||
String get historyFilterSingles => 'Singles';
|
||||
String get historyFilterSingles => 'Titres';
|
||||
|
||||
@override
|
||||
String historyTracksCount(int count) {
|
||||
@@ -93,36 +93,37 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get historyNoDownloads => 'No download history';
|
||||
String get historyNoDownloads => 'Pas d\'historique de téléchargement';
|
||||
|
||||
@override
|
||||
String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here';
|
||||
String get historyNoDownloadsSubtitle =>
|
||||
'Les pistes téléchargées apparaîtront ici';
|
||||
|
||||
@override
|
||||
String get historyNoAlbums => 'No album downloads';
|
||||
String get historyNoAlbums => 'Pas de téléchargement d\'album';
|
||||
|
||||
@override
|
||||
String get historyNoAlbumsSubtitle =>
|
||||
'Download multiple tracks from an album to see them here';
|
||||
'Téléchargez plusieurs titres d\'un album pour les voir ici';
|
||||
|
||||
@override
|
||||
String get historyNoSingles => 'No single downloads';
|
||||
String get historyNoSingles => 'Pas de téléchargements uniques';
|
||||
|
||||
@override
|
||||
String get historyNoSinglesSubtitle =>
|
||||
'Single track downloads will appear here';
|
||||
'Les téléchargements de pistes uniques apparaîtront ici';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
String get historySearchHint => 'Historique de recherche...';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
String get settingsTitle => 'Paramètres';
|
||||
|
||||
@override
|
||||
String get settingsDownload => 'Download';
|
||||
String get settingsDownload => 'Télécharger';
|
||||
|
||||
@override
|
||||
String get settingsAppearance => 'Appearance';
|
||||
String get settingsAppearance => 'Apparence';
|
||||
|
||||
@override
|
||||
String get settingsOptions => 'Options';
|
||||
@@ -131,51 +132,54 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get settingsExtensions => 'Extensions';
|
||||
|
||||
@override
|
||||
String get settingsAbout => 'About';
|
||||
String get settingsAbout => 'À propos';
|
||||
|
||||
@override
|
||||
String get downloadTitle => 'Download';
|
||||
String get downloadTitle => 'Télécharger';
|
||||
|
||||
@override
|
||||
String get downloadLocation => 'Download Location';
|
||||
String get downloadLocation => 'Télécharger Localisation';
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle => 'Choose where to save files';
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choisissez où enregistrer des fichiers';
|
||||
|
||||
@override
|
||||
String get downloadLocationDefault => 'Default location';
|
||||
String get downloadLocationDefault => 'Localisation par défaut';
|
||||
|
||||
@override
|
||||
String get downloadDefaultService => 'Default Service';
|
||||
String get downloadDefaultService => 'Service par défaut';
|
||||
|
||||
@override
|
||||
String get downloadDefaultServiceSubtitle => 'Service used for downloads';
|
||||
String get downloadDefaultServiceSubtitle =>
|
||||
'Service utilisé pour les téléchargements';
|
||||
|
||||
@override
|
||||
String get downloadDefaultQuality => 'Default Quality';
|
||||
String get downloadDefaultQuality => 'Qualité par défaut';
|
||||
|
||||
@override
|
||||
String get downloadAskQuality => 'Ask Quality Before Download';
|
||||
String get downloadAskQuality =>
|
||||
'Demandez La Qualité Avant Le Téléchargement';
|
||||
|
||||
@override
|
||||
String get downloadAskQualitySubtitle =>
|
||||
'Show quality picker for each download';
|
||||
'Afficher le sélecteur de qualité pour chaque téléchargement';
|
||||
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Filename Format';
|
||||
String get downloadFilenameFormat => 'Nom du fichier';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Folder Organization';
|
||||
String get downloadFolderOrganization => 'Organisation du dossier';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSingles => 'Separate Singles';
|
||||
String get downloadSeparateSingles => 'Titres séparés';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesSubtitle =>
|
||||
'Put single tracks in a separate folder';
|
||||
'Mettre des pistes uniques dans un dossier séparé';
|
||||
|
||||
@override
|
||||
String get qualityBest => 'Best Available';
|
||||
String get qualityBest => 'Meilleur Disponible';
|
||||
|
||||
@override
|
||||
String get qualityFlac => 'FLAC';
|
||||
@@ -187,69 +191,71 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get quality128 => '128 kbps';
|
||||
|
||||
@override
|
||||
String get appearanceTitle => 'Appearance';
|
||||
String get appearanceTitle => 'Apparence';
|
||||
|
||||
@override
|
||||
String get appearanceTheme => 'Theme';
|
||||
String get appearanceTheme => 'Thème';
|
||||
|
||||
@override
|
||||
String get appearanceThemeSystem => 'System';
|
||||
String get appearanceThemeSystem => 'Système';
|
||||
|
||||
@override
|
||||
String get appearanceThemeLight => 'Light';
|
||||
String get appearanceThemeLight => 'Clair';
|
||||
|
||||
@override
|
||||
String get appearanceThemeDark => 'Dark';
|
||||
String get appearanceThemeDark => 'Sombre';
|
||||
|
||||
@override
|
||||
String get appearanceDynamicColor => 'Dynamic Color';
|
||||
String get appearanceDynamicColor => 'Couleur dynamique';
|
||||
|
||||
@override
|
||||
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
|
||||
String get appearanceDynamicColorSubtitle =>
|
||||
'Utilisez les couleurs de votre fond d\'écran';
|
||||
|
||||
@override
|
||||
String get appearanceAccentColor => 'Accent Color';
|
||||
String get appearanceAccentColor => 'Couleur d\'accent';
|
||||
|
||||
@override
|
||||
String get appearanceHistoryView => 'History View';
|
||||
String get appearanceHistoryView => 'Historique Vue';
|
||||
|
||||
@override
|
||||
String get appearanceHistoryViewList => 'List';
|
||||
String get appearanceHistoryViewList => '';
|
||||
|
||||
@override
|
||||
String get appearanceHistoryViewGrid => 'Grid';
|
||||
String get appearanceHistoryViewGrid => 'Grille';
|
||||
|
||||
@override
|
||||
String get optionsTitle => 'Options';
|
||||
|
||||
@override
|
||||
String get optionsSearchSource => 'Search Source';
|
||||
String get optionsSearchSource => 'Recherche Source';
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProvider => 'Primary Provider';
|
||||
String get optionsPrimaryProvider => 'Fournisseur principal';
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Service used when searching by track name.';
|
||||
'Service utilisé lors de la recherche par nom de piste.';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
return 'Using extension: $extensionName';
|
||||
return 'Utilisation de l\'extension: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Auto Fallback';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
'Essayez d\'autres services si le téléchargement échoue';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
String get optionsUseExtensionProviders =>
|
||||
'Utiliser des fournisseurs d\'extension';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||
@@ -376,16 +382,16 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionsUninstall => 'Uninstall';
|
||||
String get extensionsUninstall => 'Désinstaller';
|
||||
|
||||
@override
|
||||
String get extensionsSetAsSearch => 'Set as Search Provider';
|
||||
String get extensionsSetAsSearch => 'Défini comme fournisseur de recherche';
|
||||
|
||||
@override
|
||||
String get storeTitle => 'Extension Store';
|
||||
String get storeTitle => 'Magasin d\'extension';
|
||||
|
||||
@override
|
||||
String get storeSearch => 'Search extensions...';
|
||||
String get storeSearch => 'Recherche d\'extensions...';
|
||||
|
||||
@override
|
||||
String get storeInstall => 'Install';
|
||||
@@ -567,7 +573,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get trackMetadataDuration => 'Duration';
|
||||
|
||||
@override
|
||||
String get trackMetadataQuality => 'Quality';
|
||||
String get trackMetadataQuality => '';
|
||||
|
||||
@override
|
||||
String get trackMetadataPath => 'File Path';
|
||||
@@ -579,38 +585,38 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get trackMetadataService => 'Service';
|
||||
|
||||
@override
|
||||
String get trackMetadataPlay => 'Play';
|
||||
String get trackMetadataPlay => 'Jouer';
|
||||
|
||||
@override
|
||||
String get trackMetadataShare => 'Share';
|
||||
String get trackMetadataShare => 'Partager';
|
||||
|
||||
@override
|
||||
String get trackMetadataDelete => 'Delete';
|
||||
String get trackMetadataDelete => 'Supprimer';
|
||||
|
||||
@override
|
||||
String get trackMetadataRedownload => 'Re-download';
|
||||
String get trackMetadataRedownload => 'Re-télécharger';
|
||||
|
||||
@override
|
||||
String get trackMetadataOpenFolder => 'Open Folder';
|
||||
String get trackMetadataOpenFolder => 'Dossier ouvert';
|
||||
|
||||
@override
|
||||
String get setupTitle => 'Welcome to SpotiFLAC';
|
||||
String get setupTitle => 'Bienvenue chez SpotiFLAC';
|
||||
|
||||
@override
|
||||
String get setupSubtitle => 'Let\'s get you started';
|
||||
String get setupSubtitle => 'On va commencer';
|
||||
|
||||
@override
|
||||
String get setupStoragePermission => 'Storage Permission';
|
||||
String get setupStoragePermission => 'Permission de stockage';
|
||||
|
||||
@override
|
||||
String get setupStoragePermissionSubtitle =>
|
||||
'Required to save downloaded files';
|
||||
'Requis pour enregistrer les fichiers téléchargés';
|
||||
|
||||
@override
|
||||
String get setupStoragePermissionGranted => 'Permission granted';
|
||||
String get setupStoragePermissionGranted => 'Permission accordée';
|
||||
|
||||
@override
|
||||
String get setupStoragePermissionDenied => 'Permission denied';
|
||||
String get setupStoragePermissionDenied => 'Permission refusée';
|
||||
|
||||
@override
|
||||
String get setupGrantPermission => 'Grant Permission';
|
||||
@@ -735,14 +741,14 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
'Get notified when downloads complete or require attention.';
|
||||
|
||||
@override
|
||||
String get setupFolderSelected => 'Download Folder Selected!';
|
||||
String get setupFolderSelected => 'Dossier de téléchargement sélectionné!';
|
||||
|
||||
@override
|
||||
String get setupFolderChoose => 'Choose Download Folder';
|
||||
String get setupFolderChoose => 'Choisissez le dossier pour télécharger';
|
||||
|
||||
@override
|
||||
String get setupFolderDescription =>
|
||||
'Select a folder where your downloaded music will be saved.';
|
||||
'Sélectionnez un dossier dans lequel votre musique téléchargée sera enregistrée.';
|
||||
|
||||
@override
|
||||
String get setupChangeFolder => 'Change Folder';
|
||||
@@ -1182,6 +1188,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Folder Organization';
|
||||
|
||||
@@ -1916,6 +1929,30 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
|
||||
@@ -1182,6 +1182,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Folder Organization';
|
||||
|
||||
@@ -1916,6 +1923,30 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
|
||||
@@ -349,7 +349,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.';
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Ekstensi';
|
||||
@@ -1188,6 +1188,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Aktifkan tag format untuk padding nomor lagu dan pola tanggal';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Organisasi Folder';
|
||||
|
||||
@@ -1928,6 +1935,30 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'Bitrate Opus YouTube';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'Bitrate MP3 YouTube';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Masukkan bitrate manual ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate harus antara $min dan $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
||||
|
||||
@@ -1941,27 +1972,26 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders =>
|
||||
'Gunakan Album Artist untuk folder';
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Folder artis memakai Album Artist jika tersedia';
|
||||
'Artist folders use Album Artist when available';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Folder artis hanya memakai Track Artist';
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Hanya artis utama untuk folder';
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artist dihapus dari nama folder (misal Justin Bieber, Quavo → Justin Bieber)';
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Nama artis lengkap dipakai untuk folder';
|
||||
'Full artist string used for folder name';
|
||||
|
||||
@override
|
||||
String get downloadSaveFormat => 'Simpan Format';
|
||||
@@ -2200,10 +2230,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'Belum ada item terbaru';
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Tampilkan Semua Download';
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
@@ -2312,10 +2342,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Penyimpanan & Cache';
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'Lihat ukuran dan bersihkan data cache';
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
@@ -2590,221 +2620,219 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Metadata, cover art, dan lirik otomatis tertanam';
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Mencari Musik';
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
|
||||
'There are two easy ways to find music you want to download.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Tempel URL Spotify atau Deezer langsung di kotak pencarian';
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Atau ketik nama lagu, artis, atau album untuk mencari';
|
||||
'Or type the song name, artist, or album to search';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Mendukung lagu, album, playlist, dan halaman artis';
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Mengunduh Musik';
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Mengunduh musik itu mudah dan cepat. Begini caranya.';
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh';
|
||||
'Tap the download button next to any track to start downloading';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)';
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Unduh seluruh album atau playlist dengan satu ketukan';
|
||||
'Download entire albums or playlists with one tap';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Perpustakaan Anda';
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'Semua musik yang Anda unduh terorganisir di tab Perpustakaan.';
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'Lihat progres unduhan dan antrian di tab Perpustakaan';
|
||||
'View download progress and queue in the Library tab';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Ketuk lagu mana pun untuk memutarnya dengan pemutar musik';
|
||||
'Tap any track to play it with your music player';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik';
|
||||
'Switch between list and grid view for better browsing';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Ekstensi';
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.';
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Jelajahi tab Toko untuk menemukan ekstensi berguna';
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Tambahkan provider unduhan atau sumber pencarian baru';
|
||||
'Add new download providers or search sources';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Dapatkan lirik, metadata lebih baik, dan fitur lainnya';
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Sesuaikan Pengalaman Anda';
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
'Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.';
|
||||
'Personalize the app in Settings to match your preferences.';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Ubah lokasi unduhan dan organisasi folder';
|
||||
'Change download location and folder organization';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Atur kualitas audio dan preferensi format default';
|
||||
'Set default audio quality and format preferences';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Sesuaikan tema dan tampilan aplikasi';
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'Anda siap! Mulai unduh musik favorit Anda sekarang.';
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'CONTOH';
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Pindai Ulang Penuh';
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle =>
|
||||
'Pindai ulang semua file, abaikan cache';
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Bersihkan Entri Unduhan Tidak Valid';
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsSubtitle =>
|
||||
'Hapus entri riwayat untuk file yang tidak ada lagi';
|
||||
'Remove history entries for files that no longer exist';
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Menghapus $count entri unduhan tidak valid dari riwayat';
|
||||
return 'Removed $count orphaned entries from history';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloadsNone =>
|
||||
'Tidak ada entri unduhan tidak valid';
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Penyimpanan & Cache';
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Ringkasan cache';
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
'Membersihkan cache tidak akan menghapus file musik yang sudah diunduh.';
|
||||
'Clearing cache will not remove downloaded music files.';
|
||||
|
||||
@override
|
||||
String cacheEstimatedTotal(String size) {
|
||||
return 'Estimasi penggunaan cache: $size';
|
||||
return 'Estimated cache usage: $size';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Data Cache';
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Perawatan';
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'Direktori cache aplikasi';
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'Respons HTTP, data WebView, dan data sementara aplikasi.';
|
||||
'HTTP responses, WebView data, and other temporary app data.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Direktori sementara';
|
||||
String get cacheTempDirectory => 'Temporary directory';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
'File sementara dari proses download dan konversi audio.';
|
||||
'Temporary files from downloads and audio conversion.';
|
||||
|
||||
@override
|
||||
String get cacheCoverImage => 'Cache gambar cover';
|
||||
String get cacheCoverImage => 'Cover image cache';
|
||||
|
||||
@override
|
||||
String get cacheCoverImageDesc =>
|
||||
'Gambar cover album dan lagu yang diunduh. Akan diunduh ulang saat dilihat.';
|
||||
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCover => 'Cache cover library';
|
||||
String get cacheLibraryCover => 'Library cover cache';
|
||||
|
||||
@override
|
||||
String get cacheLibraryCoverDesc =>
|
||||
'Cover dari file musik lokal. Akan diekstrak ulang saat scan berikutnya.';
|
||||
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeed => 'Cache feed Explore';
|
||||
String get cacheExploreFeed => 'Explore feed cache';
|
||||
|
||||
@override
|
||||
String get cacheExploreFeedDesc =>
|
||||
'Konten tab Explore (rilis baru, trending). Akan dimuat ulang saat dikunjungi.';
|
||||
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookup => 'Cache pencocokan lagu';
|
||||
String get cacheTrackLookup => 'Track lookup cache';
|
||||
|
||||
@override
|
||||
String get cacheTrackLookupDesc =>
|
||||
'Cache pencarian ID lagu Spotify/Deezer. Menghapus mungkin memperlambat beberapa pencarian.';
|
||||
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Hapus entri riwayat download dan library yang filenya sudah tidak ada.';
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'Tidak ada data cache';
|
||||
String get cacheNoData => 'No cached data';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size dalam $count file';
|
||||
return '$size in $count files';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2814,126 +2842,123 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String cacheEntries(int count) {
|
||||
return '$count entri';
|
||||
return '$count entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Berhasil dibersihkan: $target';
|
||||
return 'Cleared: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Bersihkan cache?';
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
return 'Ini akan membersihkan data cache untuk $target. File musik yang sudah diunduh tidak akan dihapus.';
|
||||
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Bersihkan semua cache?';
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'Ini akan membersihkan semua kategori cache di halaman ini. File musik yang sudah diunduh tidak akan dihapus.';
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Bersihkan semua cache';
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Bersihkan data tidak terpakai';
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Hapus riwayat unduhan yatim dan entri library yang file-nya hilang';
|
||||
'Remove orphaned download history and missing library entries';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
return 'Pembersihan selesai: $downloadCount unduhan yatim, $libraryCount entri library hilang';
|
||||
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Segarkan statistik';
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Simpan Cover Art';
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle =>
|
||||
'Simpan cover album sebagai file .jpg';
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Simpan Lirik (.lrc)';
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle =>
|
||||
'Ambil dan simpan lirik sebagai file .lrc';
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Menyimpan lirik...';
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
|
||||
@override
|
||||
String get trackReEnrich => 'Perkaya Ulang Metadata';
|
||||
String get trackReEnrich => 'Re-enrich Metadata';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSubtitle =>
|
||||
'Tanamkan ulang metadata tanpa mengunduh ulang';
|
||||
'Re-embed metadata without re-downloading';
|
||||
|
||||
@override
|
||||
String get trackReEnrichOnlineSubtitle =>
|
||||
'Cari metadata dari internet dan tanamkan ke file';
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art disimpan ke $fileName';
|
||||
return 'Cover art saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'Tidak ada sumber cover art';
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lirik disimpan ke $fileName';
|
||||
return 'Lyrics saved to $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Memperkaya ulang metadata...';
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Mencari metadata dari internet...';
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata berhasil diperkaya ulang';
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed =>
|
||||
'Gagal menanamkan metadata via FFmpeg';
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Gagal: $error';
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFormat => 'Konversi Format';
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Konversi ke MP3 atau Opus';
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Konversi Audio';
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@override
|
||||
String get trackConvertTargetFormat => 'Format Tujuan';
|
||||
String get trackConvertTargetFormat => 'Target Format';
|
||||
|
||||
@override
|
||||
String get trackConvertBitrate => 'Bitrate';
|
||||
|
||||
@override
|
||||
String get trackConvertConfirmTitle => 'Konfirmasi Konversi';
|
||||
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessage(
|
||||
@@ -2941,17 +2966,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String targetFormat,
|
||||
String bitrate,
|
||||
) {
|
||||
return 'Konversi dari $sourceFormat ke $targetFormat pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Mengkonversi audio...';
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@override
|
||||
String trackConvertSuccess(String format) {
|
||||
return 'Berhasil dikonversi ke $format';
|
||||
return 'Converted to $format successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertFailed => 'Konversi gagal';
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
}
|
||||
|
||||
@@ -1176,6 +1176,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'フォルダ構成';
|
||||
|
||||
@@ -1904,6 +1911,30 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get appDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'Spotify 트랙을 Tidal, Qobuz, Amazon Music에서 무손실 음질로 다운로드하세요.';
|
||||
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
@@ -34,32 +34,32 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get homeTitle => 'Home';
|
||||
|
||||
@override
|
||||
String get homeSearchHint => 'Paste Spotify URL or search...';
|
||||
String get homeSearchHint => 'Spotify URL을 붙여 넣거나 검색';
|
||||
|
||||
@override
|
||||
String homeSearchHintExtension(String extensionName) {
|
||||
return 'Search with $extensionName...';
|
||||
return '$extensionName에서 검색';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
||||
String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색';
|
||||
|
||||
@override
|
||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
||||
String get homeSupports => '지원 항목: 트랙, 앨범, 플레이리스트, 아티스트 URLs';
|
||||
|
||||
@override
|
||||
String get homeRecent => 'Recent';
|
||||
String get homeRecent => '최근 기록';
|
||||
|
||||
@override
|
||||
String get historyTitle => 'History';
|
||||
String get historyTitle => '기록';
|
||||
|
||||
@override
|
||||
String historyDownloading(int count) {
|
||||
return 'Downloading ($count)';
|
||||
return '다운로드 중... $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get historyDownloaded => 'Downloaded';
|
||||
String get historyDownloaded => '다운로드 목록';
|
||||
|
||||
@override
|
||||
String get historyFilterAll => 'All';
|
||||
@@ -75,7 +75,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
other: '${count}tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
@@ -245,14 +245,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get optionsAutoFallback => 'Auto Fallback';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||
String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
||||
@@ -1182,6 +1181,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Folder Organization';
|
||||
|
||||
@@ -1916,6 +1922,30 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
|
||||
@@ -1182,6 +1182,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Folder Organization';
|
||||
|
||||
@@ -1916,6 +1923,30 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get navHome => 'Главная';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
String get navLibrary => 'Библиотека';
|
||||
|
||||
@override
|
||||
String get navHistory => 'История';
|
||||
@@ -356,7 +356,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
'Поиск Spotify устареет 3 марта 2026 года из-за изменений Spotify API. Пожалуйста, перейдите на Deezer.';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Расширения';
|
||||
@@ -486,7 +486,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||
'Создатель I Don\'t Have Spotify (IDHS). Резервный резолвер ссылки';
|
||||
|
||||
@override
|
||||
String get aboutDoubleDouble => 'DoubleDouble';
|
||||
@@ -507,7 +507,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutSpotiSaverDesc =>
|
||||
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||
'Потоковая передача Tidal Hi-Res FLAC. Ключевая часть lossless головоломки!';
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
@@ -712,7 +712,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get setupIcloudNotSupported =>
|
||||
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||
'iCloud Drive не поддерживается. Пожалуйста, используйте папку Документы.';
|
||||
|
||||
@override
|
||||
String get setupDownloadInFlac => 'Скачать Spotify треки во FLAC';
|
||||
@@ -975,7 +975,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String snackbarAlreadyInLibrary(String trackName) {
|
||||
return '\"$trackName\" already exists in your library';
|
||||
return '\"$trackName\" уже есть в вашей библиотеке';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1209,6 +1209,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Организация папок';
|
||||
|
||||
@@ -1918,33 +1925,35 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get qualityLossy => 'Lossy';
|
||||
|
||||
@override
|
||||
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||
String get qualityLossyMp3Subtitle =>
|
||||
'Opus 320 кбит/с (конвертировать из FLAC)';
|
||||
|
||||
@override
|
||||
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||
String get qualityLossyOpusSubtitle =>
|
||||
'Opus 128 кбит/с (конвертировать из FLAC)';
|
||||
|
||||
@override
|
||||
String get enableLossyOption => 'Enable Lossy Option';
|
||||
String get enableLossyOption => 'Включить опцию Lossy';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||
String get enableLossyOptionSubtitleOn => 'Доступно качество с потерями';
|
||||
|
||||
@override
|
||||
String get enableLossyOptionSubtitleOff =>
|
||||
'Downloads FLAC then converts to lossy format';
|
||||
'Скачивать FLAC и конвертировать в MP3 320 кбит/с';
|
||||
|
||||
@override
|
||||
String get lossyFormat => 'Lossy Format';
|
||||
String get lossyFormat => 'Формат с потерями';
|
||||
|
||||
@override
|
||||
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||
String get lossyFormatDescription => 'Выберите Lossy формат для конвертации';
|
||||
|
||||
@override
|
||||
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||
String get lossyFormatMp3Subtitle => '320Кбит/с, лучшая совместимость';
|
||||
|
||||
@override
|
||||
String get lossyFormatOpusSubtitle =>
|
||||
'128kbps, better quality at smaller size';
|
||||
'128кбит/с, лучшее качество при меньших размерах';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
@@ -1952,7 +1961,31 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
'YouTube обеспечивает только звук с потерями(Lossy).';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
|
||||
@@ -1967,7 +2000,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get downloadAlbumFolderStructure => 'Структура папок альбома';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
String get downloadUseAlbumArtistForFolders =>
|
||||
'Использовать исполнителя альбома для папок';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
@@ -1975,7 +2009,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Папки исполнителя используют только трек исполнителя';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
@@ -2069,37 +2103,37 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Вы уверены, что хотите очистить все загрузки?';
|
||||
|
||||
@override
|
||||
String get queueExportFailed => 'Export';
|
||||
String get queueExportFailed => 'Экспорт';
|
||||
|
||||
@override
|
||||
String get queueExportFailedSuccess =>
|
||||
'Failed downloads exported to TXT file';
|
||||
'Сбой при экспорте загрузок в файл TXT';
|
||||
|
||||
@override
|
||||
String get queueExportFailedClear => 'Clear Failed';
|
||||
String get queueExportFailedClear => 'Не удалось очистить';
|
||||
|
||||
@override
|
||||
String get queueExportFailedError => 'Failed to export downloads';
|
||||
String get queueExportFailedError => 'Не удалось экспортировать загрузки';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
String get settingsAutoExportFailed => 'Автоэкспорт неудачных загрузок';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
'Автоматическое сохранение неудачных загрузок в TXT файл';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
String get settingsDownloadNetwork => 'Сеть для скачивания';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
String get settingsDownloadNetworkAny => 'WiFi и мобильная сеть';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
String get settingsDownloadNetworkWifiOnly => 'Только WiFi';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
'Выберите, какую сеть использовать для скачивания. Когда установлено значение только WiFi — скачивания через мобильную сеть будут приостановлены.';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'Нет загрузок в очереди';
|
||||
@@ -2231,10 +2265,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get recentTypePlaylist => 'Плейлист';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
String get recentEmpty => 'Нет недавних элементов';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
String get recentShowAllDownloads => 'Показать все загрузки';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
@@ -2314,234 +2348,254 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Не удалось получить некоторые альбомы';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Storage Access';
|
||||
String get sectionStorageAccess => 'Доступ к хранилищу';
|
||||
|
||||
@override
|
||||
String get allFilesAccess => 'All Files Access';
|
||||
String get allFilesAccess => 'Доступ ко всем файлам';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
String get allFilesAccessEnabledSubtitle => 'Можно записать в любую папку';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
String get allFilesAccessDisabledSubtitle =>
|
||||
'Ограничено только папками медиа';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||
'Включите, если вы сталкиваетесь с ошибками записи при сохранении в пользовательские папки. Android 13+ по умолчанию ограничивает доступ к определенным папкам.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||
'В разрешении отказано. Пожалуйста, включите функцию «Доступ ко всем файлам» в настройках системы.';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledMessage =>
|
||||
'All Files Access disabled. The app will use limited storage access.';
|
||||
'Доступ ко всем файлам отключен. Приложение будет использовать ограниченный доступ к хранилищу.';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrary => 'Local Library';
|
||||
String get settingsLocalLibrary => 'Локальная библиотека';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
String get settingsLocalLibrarySubtitle =>
|
||||
'Сканировать и обнаружить дубликаты';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Storage & Cache';
|
||||
String get settingsCache => 'Хранилище и кэш';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
String get settingsCacheSubtitle => 'Просмотреть размер и очистить кэш';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Local Library';
|
||||
String get libraryTitle => 'Локальная библиотека';
|
||||
|
||||
@override
|
||||
String get libraryStatus => 'Library Status';
|
||||
String get libraryStatus => 'Статус Библиотеки';
|
||||
|
||||
@override
|
||||
String get libraryScanSettings => 'Scan Settings';
|
||||
String get libraryScanSettings => 'Настройки сканирования';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||
String get libraryEnableLocalLibrary => 'Включить локальную библиотеку';
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
'Сканировать и отслеживать вашу существующую музыку';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Library Folder';
|
||||
String get libraryFolder => 'Папка библиотеки';
|
||||
|
||||
@override
|
||||
String get libraryFolderHint => 'Tap to select folder';
|
||||
String get libraryFolderHint => 'Нажмите, чтобы выбрать папку';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
String get libraryShowDuplicateIndicator => 'Показать индикатор дубликатов';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
'Показать при поиске существующих треков';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
String get libraryActions => 'Действия';
|
||||
|
||||
@override
|
||||
String get libraryScan => 'Scan Library';
|
||||
String get libraryScan => 'Сканировать библиотеку';
|
||||
|
||||
@override
|
||||
String get libraryScanSubtitle => 'Scan for audio files';
|
||||
String get libraryScanSubtitle => 'Сканировать аудио файлы';
|
||||
|
||||
@override
|
||||
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||
String get libraryScanSelectFolderFirst => 'Сначала выберите папку';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||
String get libraryCleanupMissingFiles => 'Очистка отсутствующих файлов';
|
||||
|
||||
@override
|
||||
String get libraryCleanupMissingFilesSubtitle =>
|
||||
'Remove entries for files that no longer exist';
|
||||
'Удалить записи для файлов, которых больше не существует';
|
||||
|
||||
@override
|
||||
String get libraryClear => 'Clear Library';
|
||||
String get libraryClear => 'Очистить библиотеку';
|
||||
|
||||
@override
|
||||
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||
String get libraryClearSubtitle => 'Удалить все сканированные треки';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmTitle => 'Clear Library';
|
||||
String get libraryClearConfirmTitle => 'Очистить библиотеку';
|
||||
|
||||
@override
|
||||
String get libraryClearConfirmMessage =>
|
||||
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||
'Это удалит все сканированные треки из вашей библиотеки. Ваши фактические файлы не будут удалены.';
|
||||
|
||||
@override
|
||||
String get libraryAbout => 'About Local Library';
|
||||
String get libraryAbout => 'О локальной библиотеке';
|
||||
|
||||
@override
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
'Сканирует существующую коллекцию музыки для обнаружения дубликатов при загрузке. Поддерживает форматы FLAC, M4A, MP3, Opus и OGG. Метаданные читаются из тегов файлов, если доступны.';
|
||||
|
||||
@override
|
||||
String libraryTracksCount(int count) {
|
||||
return '$count tracks';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
return 'Последнее сканирование: $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryLastScannedNever => 'Never';
|
||||
String get libraryLastScannedNever => 'Никогда';
|
||||
|
||||
@override
|
||||
String get libraryScanning => 'Scanning...';
|
||||
String get libraryScanning => 'Сканирование...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
return '$progress% of $total files';
|
||||
return '$progress% из $total файлов';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryInLibrary => 'In Library';
|
||||
String get libraryInLibrary => 'В библиотеке';
|
||||
|
||||
@override
|
||||
String libraryRemovedMissingFiles(int count) {
|
||||
return 'Removed $count missing files from library';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'отсутствующих файлов',
|
||||
many: 'отсутствующих файлов',
|
||||
few: 'трека',
|
||||
one: 'отсутствующий файл',
|
||||
);
|
||||
return 'Удалено $count $_temp0 в библиотеке';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryCleared => 'Library cleared';
|
||||
String get libraryCleared => 'Библиотека очищена';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||
String get libraryStorageAccessRequired => 'Требуется доступ к хранилищу';
|
||||
|
||||
@override
|
||||
String get libraryStorageAccessMessage =>
|
||||
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||
'SpotiFLAC требуется доступ к хранилищу для сканирования вашей библиотеки музыки. Пожалуйста, предоставьте разрешение в настройках.';
|
||||
|
||||
@override
|
||||
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||
String get libraryFolderNotExist => 'Выбранной папки не существует';
|
||||
|
||||
@override
|
||||
String get librarySourceDownloaded => 'Downloaded';
|
||||
String get librarySourceDownloaded => 'Скачанные';
|
||||
|
||||
@override
|
||||
String get librarySourceLocal => 'Local';
|
||||
String get librarySourceLocal => 'Локальные';
|
||||
|
||||
@override
|
||||
String get libraryFilterAll => 'All';
|
||||
String get libraryFilterAll => 'Все';
|
||||
|
||||
@override
|
||||
String get libraryFilterDownloaded => 'Downloaded';
|
||||
String get libraryFilterDownloaded => 'Скачанные';
|
||||
|
||||
@override
|
||||
String get libraryFilterLocal => 'Local';
|
||||
String get libraryFilterLocal => 'Локальные';
|
||||
|
||||
@override
|
||||
String get libraryFilterTitle => 'Filters';
|
||||
String get libraryFilterTitle => 'Фильтры';
|
||||
|
||||
@override
|
||||
String get libraryFilterReset => 'Reset';
|
||||
String get libraryFilterReset => 'Сброс';
|
||||
|
||||
@override
|
||||
String get libraryFilterApply => 'Apply';
|
||||
String get libraryFilterApply => 'Применить';
|
||||
|
||||
@override
|
||||
String get libraryFilterSource => 'Source';
|
||||
String get libraryFilterSource => 'Источник';
|
||||
|
||||
@override
|
||||
String get libraryFilterQuality => 'Quality';
|
||||
String get libraryFilterQuality => 'Качество';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||
String get libraryFilterQualityHiRes => 'Hi-Res (24 бит)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||
String get libraryFilterQualityCD => 'CD (16 бит)';
|
||||
|
||||
@override
|
||||
String get libraryFilterQualityLossy => 'Lossy';
|
||||
String get libraryFilterQualityLossy => 'С потерями';
|
||||
|
||||
@override
|
||||
String get libraryFilterFormat => 'Format';
|
||||
String get libraryFilterFormat => 'Формат';
|
||||
|
||||
@override
|
||||
String get libraryFilterDate => 'Date Added';
|
||||
String get libraryFilterDate => 'Дата добавления';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateToday => 'Today';
|
||||
String get libraryFilterDateToday => 'Сегодня';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateWeek => 'This Week';
|
||||
String get libraryFilterDateWeek => 'На этой неделе';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateMonth => 'This Month';
|
||||
String get libraryFilterDateMonth => 'В этом месяце';
|
||||
|
||||
@override
|
||||
String get libraryFilterDateYear => 'This Year';
|
||||
String get libraryFilterDateYear => 'В этом году';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sort';
|
||||
String get libraryFilterSort => 'Сортировка';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortLatest => 'Latest';
|
||||
String get libraryFilterSortLatest => 'Последние';
|
||||
|
||||
@override
|
||||
String get libraryFilterSortOldest => 'Oldest';
|
||||
String get libraryFilterSortOldest => 'Старые';
|
||||
|
||||
@override
|
||||
String libraryFilterActive(int count) {
|
||||
return '$count filter(s) active';
|
||||
return '$count фильтр(-ов) активно';
|
||||
}
|
||||
|
||||
@override
|
||||
String get timeJustNow => 'Just now';
|
||||
String get timeJustNow => 'Только что';
|
||||
|
||||
@override
|
||||
String timeMinutesAgo(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes ago',
|
||||
one: '1 minute ago',
|
||||
other: '$count минут',
|
||||
many: '$count минут',
|
||||
few: '$count минуты',
|
||||
one: '$count минуту',
|
||||
);
|
||||
return '$_temp0';
|
||||
return '$_temp0 назад';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2549,160 +2603,186 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours ago',
|
||||
one: '1 hour ago',
|
||||
other: '$count часов',
|
||||
many: '$count часов',
|
||||
few: '$count часа',
|
||||
one: '$count час',
|
||||
);
|
||||
return '$_temp0';
|
||||
return '$_temp0 назад';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||
String get storageSwitchTitle => 'Сменить режим хранения';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||
String get storageSwitchToSafTitle => 'Переключиться на SAF хранилище?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||
String get storageSwitchToAppTitle => 'Переключиться хранилище приложения?';
|
||||
|
||||
@override
|
||||
String get storageSwitchToSafMessage =>
|
||||
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||
'Ваши скачанные файлы останутся в текущем расположении и будут доступны.\n\nНовые файлы будут сохранены в выбранной вами папке SAF.';
|
||||
|
||||
@override
|
||||
String get storageSwitchToAppMessage =>
|
||||
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||
'Ваши скачанные файлы останутся в текущем выбранной вами папке SAF.\n\nНовые файлы будут сохранены в папке Music/SpotiFLAC.';
|
||||
|
||||
@override
|
||||
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||
String get storageSwitchExistingDownloads => 'Существующие загрузки';
|
||||
|
||||
@override
|
||||
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||
return '$count tracks in $mode storage';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0 в $mode хранилище';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchNewDownloads => 'New Downloads';
|
||||
String get storageSwitchNewDownloads => 'Новые загрузки';
|
||||
|
||||
@override
|
||||
String storageSwitchNewDownloadsLocation(String location) {
|
||||
return 'Will be saved to: $location';
|
||||
return 'Будет сохранено в: $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageSwitchContinue => 'Continue';
|
||||
String get storageSwitchContinue => 'Продолжить';
|
||||
|
||||
@override
|
||||
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||
String get storageSwitchSelectFolder => 'Выберите папку SAF';
|
||||
|
||||
@override
|
||||
String get storageAppStorage => 'App Storage';
|
||||
String get storageAppStorage => 'Хранилище приложения';
|
||||
|
||||
@override
|
||||
String get storageSafStorage => 'SAF Storage';
|
||||
String get storageSafStorage => 'Хранилище SAF';
|
||||
|
||||
@override
|
||||
String storageModeBadge(String mode) {
|
||||
return 'Storage: $mode';
|
||||
return 'Хранилище: $mode';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageStatsTitle => 'Storage Statistics';
|
||||
String get storageStatsTitle => 'Статистика хранилища';
|
||||
|
||||
@override
|
||||
String storageStatsAppCount(int count) {
|
||||
return '$count tracks in App Storage';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0 в хранилище приложения';
|
||||
}
|
||||
|
||||
@override
|
||||
String storageStatsSafCount(int count) {
|
||||
return '$count tracks in SAF Storage';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0 в вашей папке в SAF';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||
String get storageModeInfo => 'Ваши файлы хранятся в нескольких местах';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
String get tutorialWelcomeTitle => 'Добро пожаловать в SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
'Давайте научимся скачивать свою любимую музыку в качестве без потерь. В этом кратком руководстве мы покажем вам основы.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
'Скачивайте музыку из Spotify, Deezer, или вставьте любой поддерживаемый URL';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Скачайте FLAC с Tidal, Qobuz или Amazon Music';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
'Автоматическое встраивание метаданных, обложек и текстов песен';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
String get tutorialSearchTitle => 'Поиск музыки';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
'Есть два простых способа найти музыку, которую вы хотите скачать.';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip1 =>
|
||||
'Paste a Spotify or Deezer URL directly in the search box';
|
||||
'Вставьте ссылку Spotify или Deezer прямо в поле поиска';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip2 =>
|
||||
'Or type the song name, artist, or album to search';
|
||||
'Или введите название песни, исполнителя или альбом для поиска';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTip3 =>
|
||||
'Supports tracks, albums, playlists, and artist pages';
|
||||
'Поддержка треков, альбомов, плейлистов и страниц исполнителей';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
String get tutorialDownloadTitle => 'Скачивание музыки';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
'Скачивание музыки просто и быстро. Вот как это работает.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip1 =>
|
||||
'Tap the download button next to any track to start downloading';
|
||||
'Нажмите кнопку скачать рядом с любым треком, чтобы начать скачивание';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip2 =>
|
||||
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||
'Выберите предпочитаемое качество (FLAC, Hi-Res или MP3)';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTip3 =>
|
||||
'Download entire albums or playlists with one tap';
|
||||
'Скачать все альбомы или плейлисты одним нажатием';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
String get tutorialLibraryTitle => 'Ваша библиотека';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
'Вся скачанная музыка организована во вкладке Библиотека.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
'View download progress and queue in the Library tab';
|
||||
'Просмотр прогресса загрузки и очереди на вкладке Библиотека';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip2 =>
|
||||
'Tap any track to play it with your music player';
|
||||
'Нажмите на любой трек, чтобы воспроизвести его с помощью вашего музыкального плеера';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip3 =>
|
||||
'Switch between list and grid view for better browsing';
|
||||
'Переключение между списком и сеткой для лучшего просмотра';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTitle => 'Extensions';
|
||||
String get tutorialExtensionsTitle => 'Расширения';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsDesc =>
|
||||
'Extend the app\'s capabilities with community extensions.';
|
||||
'Расширьте возможности приложения с расширениями от сообщества.';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
@@ -2710,14 +2790,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
'Add new download providers or search sources';
|
||||
'Добавить новых поставщиков загрузок или поиска';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip3 =>
|
||||
'Get lyrics, enhanced metadata, and more features';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||
String get tutorialSettingsTitle => 'Настройте приложение под себя';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsDesc =>
|
||||
@@ -2725,27 +2805,28 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Change download location and folder organization';
|
||||
'Изменить местоположение и организацию папок для скачивания';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
'Set default audio quality and format preferences';
|
||||
'Настройте качество и формата аудиофайла по умолчанию';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||
String get tutorialSettingsTip3 => 'Настроить тему и внешний вид приложения';
|
||||
|
||||
@override
|
||||
String get tutorialReadyMessage =>
|
||||
'You\'re all set! Start downloading your favorite music now.';
|
||||
'Всё готово! Начните загружать любимую музыку прямо сейчас.';
|
||||
|
||||
@override
|
||||
String get tutorialExample => 'EXAMPLE';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScan => 'Force Full Scan';
|
||||
String get libraryForceFullScan => 'Полное сканирование';
|
||||
|
||||
@override
|
||||
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||
String get libraryForceFullScanSubtitle =>
|
||||
'Пересканировать все файлы, игнорировать кэш';
|
||||
|
||||
@override
|
||||
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||
@@ -2763,10 +2844,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||
|
||||
@override
|
||||
String get cacheTitle => 'Storage & Cache';
|
||||
String get cacheTitle => 'Хранилище и кэш';
|
||||
|
||||
@override
|
||||
String get cacheSummaryTitle => 'Cache overview';
|
||||
String get cacheSummaryTitle => 'Просмотр кэша';
|
||||
|
||||
@override
|
||||
String get cacheSummarySubtitle =>
|
||||
@@ -2778,13 +2859,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheSectionStorage => 'Cached Data';
|
||||
String get cacheSectionStorage => 'Кэшированные данные';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App cache directory';
|
||||
String get cacheAppDirectory => 'Папка кэша приложения';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
@@ -2830,11 +2911,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'No cached data';
|
||||
String get cacheNoData => 'Нет кэшированных данных';
|
||||
|
||||
@override
|
||||
String cacheSizeWithFiles(String size, int count) {
|
||||
return '$size in $count files';
|
||||
return '$size в $count файлах';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2849,11 +2930,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String cacheClearSuccess(String target) {
|
||||
return 'Cleared: $target';
|
||||
return 'Очищено: $target';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||
String get cacheClearConfirmTitle => 'Очистить кэш?';
|
||||
|
||||
@override
|
||||
String cacheClearConfirmMessage(String target) {
|
||||
@@ -2861,17 +2942,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||
String get cacheClearAllConfirmTitle => 'Очистить весь кэш?';
|
||||
|
||||
@override
|
||||
String get cacheClearAllConfirmMessage =>
|
||||
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||
'Это очистит все категории кэша на этой странице. Скачанные музыкальные файлы не будут удалены.';
|
||||
|
||||
@override
|
||||
String get cacheClearAll => 'Clear all cache';
|
||||
String get cacheClearAll => 'Очистить весь кэш';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||
String get cacheCleanupUnused => 'Очистка неиспользуемых данных';
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
@@ -2883,19 +2964,20 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get cacheRefreshStats => 'Refresh stats';
|
||||
String get cacheRefreshStats => 'Обновить статистику';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArt => 'Save Cover Art';
|
||||
String get trackSaveCoverArt => 'Сохранить обложку';
|
||||
|
||||
@override
|
||||
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||
String get trackSaveCoverArtSubtitle => 'Сохранить обложку как файл .jpg';
|
||||
|
||||
@override
|
||||
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||
String get trackSaveLyrics => 'Сохранить текст (.lrc)';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||
String get trackSaveLyricsSubtitle =>
|
||||
'Получить и сохранить текст песни в формате .lrc';
|
||||
|
||||
@override
|
||||
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||
@@ -2912,36 +2994,37 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Search metadata online and embed into file';
|
||||
|
||||
@override
|
||||
String get trackEditMetadata => 'Edit Metadata';
|
||||
String get trackEditMetadata => 'Редактировать метаданные';
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
return 'Обложка сохранена в $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
String get trackCoverNoSource => 'Нет доступных источников обложки';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
return 'Lyrics saved to $fileName';
|
||||
return 'Текст песни сохранен в $fileName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||
String get trackReEnrichSearching => 'Поиск метаданных в сети...';
|
||||
|
||||
@override
|
||||
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
String get trackReEnrichFfmpegFailed =>
|
||||
'Ошибка встраивания метаданных FFmpeg';
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
return 'Ошибка: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1189,6 +1189,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
return '$artist - $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganization => 'Klasör Organizasyonu';
|
||||
|
||||
@@ -1931,6 +1938,30 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String youtubeBitrateSubtitle(int bitrate, int min, int max) {
|
||||
return '${bitrate}kbps ($min-$max)';
|
||||
}
|
||||
|
||||
@override
|
||||
String youtubeBitrateInputHelp(int min, int max) {
|
||||
return 'Enter custom bitrate ($min-$max kbps)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get youtubeBitrateFieldLabel => 'Bitrate (kbps)';
|
||||
|
||||
@override
|
||||
String youtubeBitrateValidationError(int min, int max) {
|
||||
return 'Bitrate must be between $min and $max kbps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||
|
||||
|
||||
@@ -1152,7 +1152,7 @@
|
||||
"@dialogDeleteSelectedTitle": {
|
||||
"description": "Dialog title - delete selected items"
|
||||
},
|
||||
"dialogDeleteSelectedMessage": "Lösche {count} {count, plural, one {}=1{Track} other{Tracks}} aus dem Verlauf?\n\nDies löscht auch die Dateien aus dem Speicher.",
|
||||
"dialogDeleteSelectedMessage": "Lösche {count} {count, plural, one {Track} other{Tracks}} aus dem Verlauf?\n\nDies löscht auch die Dateien aus dem Speicher.",
|
||||
"@dialogDeleteSelectedMessage": {
|
||||
"description": "Dialog message - delete selected tracks",
|
||||
"placeholders": {
|
||||
@@ -1231,7 +1231,7 @@
|
||||
"@snackbarCredentialsCleared": {
|
||||
"description": "Snackbar - Spotify credentials removed"
|
||||
},
|
||||
"snackbarDeletedTracks": "{count} {count, plural, one {}=1{Titel} other{Titel}}",
|
||||
"snackbarDeletedTracks": "{count} {count, plural, one {Titel} other{Titel}}",
|
||||
"@snackbarDeletedTracks": {
|
||||
"description": "Snackbar - tracks deleted",
|
||||
"placeholders": {
|
||||
@@ -1438,7 +1438,7 @@
|
||||
"@selectionTapToSelect": {
|
||||
"description": "Hint - how to select items"
|
||||
},
|
||||
"selectionDeleteTracks": "Lösche {count} {count, plural, one {}=1{Titel}other{Titel}}",
|
||||
"selectionDeleteTracks": "Lösche {count} {count, plural, one {Titel}other{Titel}}",
|
||||
"@selectionDeleteTracks": {
|
||||
"description": "Delete button with count",
|
||||
"placeholders": {
|
||||
|
||||
@@ -874,6 +874,14 @@
|
||||
"@filenameAvailablePlaceholders": {"description": "Label for placeholder list"},
|
||||
"filenameHint": "{artist} - {title}",
|
||||
"@filenameHint": {"description": "Default filename format hint"},
|
||||
"filenameShowAdvancedTags": "Show advanced tags",
|
||||
"@filenameShowAdvancedTags": {
|
||||
"description": "Toggle label for showing advanced filename tags"
|
||||
},
|
||||
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||
"@filenameShowAdvancedTagsDescription": {
|
||||
"description": "Description for advanced filename tag toggle"
|
||||
},
|
||||
|
||||
"folderOrganization": "Folder Organization",
|
||||
"@folderOrganization": {"description": "Setting title - folder structure"},
|
||||
@@ -1412,6 +1420,37 @@
|
||||
"@qualityNote": {"description": "Note about quality availability"},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"},
|
||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||
"@youtubeOpusBitrateTitle": {"description": "Title for YouTube Opus bitrate setting"},
|
||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||
"@youtubeMp3BitrateTitle": {"description": "Title for YouTube MP3 bitrate setting"},
|
||||
"youtubeBitrateSubtitle": "{bitrate}kbps ({min}-{max})",
|
||||
"@youtubeBitrateSubtitle": {
|
||||
"description": "Subtitle showing current bitrate and valid range",
|
||||
"placeholders": {
|
||||
"bitrate": {"type": "int"},
|
||||
"min": {"type": "int"},
|
||||
"max": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"youtubeBitrateInputHelp": "Enter custom bitrate ({min}-{max} kbps)",
|
||||
"@youtubeBitrateInputHelp": {
|
||||
"description": "Helper text for manual YouTube bitrate input",
|
||||
"placeholders": {
|
||||
"min": {"type": "int"},
|
||||
"max": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"youtubeBitrateFieldLabel": "Bitrate (kbps)",
|
||||
"@youtubeBitrateFieldLabel": {"description": "Label for YouTube bitrate input field"},
|
||||
"youtubeBitrateValidationError": "Bitrate must be between {min} and {max} kbps",
|
||||
"@youtubeBitrateValidationError": {
|
||||
"description": "Validation error for invalid YouTube bitrate input",
|
||||
"placeholders": {
|
||||
"min": {"type": "int"},
|
||||
"max": {"type": "int"}
|
||||
}
|
||||
},
|
||||
|
||||
"downloadAskBeforeDownload": "Ask Before Download",
|
||||
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"@historyFilterSingles": {
|
||||
"description": "Filter chip - show singles only"
|
||||
},
|
||||
"historyTracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
|
||||
"historyTracksCount": "{count, plural, one {1 pista} other{{count} pistas}}",
|
||||
"@historyTracksCount": {
|
||||
"description": "Track count with plural form",
|
||||
"placeholders": {
|
||||
@@ -98,7 +98,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbumes}}",
|
||||
"historyAlbumsCount": "{count, plural, one {1 álbum} other{{count} álbumes}}",
|
||||
"@historyAlbumsCount": {
|
||||
"description": "Album count with plural form",
|
||||
"placeholders": {
|
||||
@@ -636,7 +636,7 @@
|
||||
"@albumTitle": {
|
||||
"description": "Album screen title"
|
||||
},
|
||||
"albumTracks": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
|
||||
"albumTracks": "{count, plural, one {1 pista} other{{count} pistas}}",
|
||||
"@albumTracks": {
|
||||
"description": "Album track count",
|
||||
"placeholders": {
|
||||
@@ -673,7 +673,7 @@
|
||||
"@artistCompilations": {
|
||||
"description": "Section header for compilations"
|
||||
},
|
||||
"artistReleases": "{count, plural, one {}=1{1 lanzamiento} other{{count} lanzamientos}}",
|
||||
"artistReleases": "{count, plural, one {1 lanzamiento} other{{count} lanzamientos}}",
|
||||
"@artistReleases": {
|
||||
"description": "Artist release count",
|
||||
"placeholders": {
|
||||
@@ -1152,7 +1152,7 @@
|
||||
"@dialogDeleteSelectedTitle": {
|
||||
"description": "Dialog title - delete selected items"
|
||||
},
|
||||
"dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
|
||||
"dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, one {pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
|
||||
"@dialogDeleteSelectedMessage": {
|
||||
"description": "Dialog message - delete selected tracks",
|
||||
"placeholders": {
|
||||
@@ -1231,7 +1231,7 @@
|
||||
"@snackbarCredentialsCleared": {
|
||||
"description": "Snackbar - Spotify credentials removed"
|
||||
},
|
||||
"snackbarDeletedTracks": "Eliminado {count} {count, plural, one {}=1{pista} other{pistas}}",
|
||||
"snackbarDeletedTracks": "Eliminado {count} {count, plural, one {pista} other{pistas}}",
|
||||
"@snackbarDeletedTracks": {
|
||||
"description": "Snackbar - tracks deleted",
|
||||
"placeholders": {
|
||||
@@ -1438,7 +1438,7 @@
|
||||
"@selectionTapToSelect": {
|
||||
"description": "Hint - how to select items"
|
||||
},
|
||||
"selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
|
||||
"selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {pista} other{pistas}}",
|
||||
"@selectionDeleteTracks": {
|
||||
"description": "Delete button with count",
|
||||
"placeholders": {
|
||||
@@ -2014,7 +2014,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
|
||||
"tracksCount": "{count, plural, one {1 pista} other{{count} pistas}}",
|
||||
"@tracksCount": {
|
||||
"description": "Track count display",
|
||||
"placeholders": {
|
||||
@@ -2758,7 +2758,7 @@
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
},
|
||||
"downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
|
||||
"downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, one {pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
|
||||
"@downloadedAlbumDeleteMessage": {
|
||||
"description": "Delete confirmation with count",
|
||||
"placeholders": {
|
||||
@@ -2797,7 +2797,7 @@
|
||||
"@downloadedAlbumTapToSelect": {
|
||||
"description": "Selection hint"
|
||||
},
|
||||
"downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
|
||||
"downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, one {pista} other{pistas}}",
|
||||
"@downloadedAlbumDeleteCount": {
|
||||
"description": "Delete button text with count",
|
||||
"placeholders": {
|
||||
|
||||
@@ -1524,15 +1524,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filenameAvailablePlaceholders": "Placeholder yang tersedia:",
|
||||
"@filenameAvailablePlaceholders": {
|
||||
"description": "Label for placeholder list"
|
||||
},
|
||||
"filenameHint": "{artist} - {title}",
|
||||
"@filenameHint": {
|
||||
"description": "Default filename format hint"
|
||||
},
|
||||
"folderOrganization": "Organisasi Folder",
|
||||
"filenameAvailablePlaceholders": "Placeholder yang tersedia:",
|
||||
"@filenameAvailablePlaceholders": {
|
||||
"description": "Label for placeholder list"
|
||||
},
|
||||
"filenameHint": "{artist} - {title}",
|
||||
"@filenameHint": {
|
||||
"description": "Default filename format hint"
|
||||
},
|
||||
"filenameShowAdvancedTags": "Tampilkan tag lanjutan",
|
||||
"@filenameShowAdvancedTags": {
|
||||
"description": "Toggle label for showing advanced filename tags"
|
||||
},
|
||||
"filenameShowAdvancedTagsDescription": "Aktifkan tag format untuk padding nomor lagu dan pola tanggal",
|
||||
"@filenameShowAdvancedTagsDescription": {
|
||||
"description": "Description for advanced filename tag toggle"
|
||||
},
|
||||
"folderOrganization": "Organisasi Folder",
|
||||
"@folderOrganization": {
|
||||
"description": "Setting title - folder structure"
|
||||
},
|
||||
@@ -2490,18 +2498,69 @@
|
||||
"@lossyFormatOpusSubtitle": {
|
||||
"description": "Opus format description"
|
||||
},
|
||||
"qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan",
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
||||
"@downloadAskBeforeDownload": {
|
||||
"description": "Setting - show quality picker"
|
||||
},
|
||||
"qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan",
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "Bitrate Opus YouTube",
|
||||
"@youtubeOpusBitrateTitle": {
|
||||
"description": "Title for YouTube Opus bitrate setting"
|
||||
},
|
||||
"youtubeMp3BitrateTitle": "Bitrate MP3 YouTube",
|
||||
"@youtubeMp3BitrateTitle": {
|
||||
"description": "Title for YouTube MP3 bitrate setting"
|
||||
},
|
||||
"youtubeBitrateSubtitle": "{bitrate}kbps ({min}-{max})",
|
||||
"@youtubeBitrateSubtitle": {
|
||||
"description": "Subtitle showing current bitrate and valid range",
|
||||
"placeholders": {
|
||||
"bitrate": {
|
||||
"type": "int"
|
||||
},
|
||||
"min": {
|
||||
"type": "int"
|
||||
},
|
||||
"max": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"youtubeBitrateInputHelp": "Masukkan bitrate manual ({min}-{max} kbps)",
|
||||
"@youtubeBitrateInputHelp": {
|
||||
"description": "Helper text for manual YouTube bitrate input",
|
||||
"placeholders": {
|
||||
"min": {
|
||||
"type": "int"
|
||||
},
|
||||
"max": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"youtubeBitrateFieldLabel": "Bitrate (kbps)",
|
||||
"@youtubeBitrateFieldLabel": {
|
||||
"description": "Label for YouTube bitrate input field"
|
||||
},
|
||||
"youtubeBitrateValidationError": "Bitrate harus antara {min} dan {max} kbps",
|
||||
"@youtubeBitrateValidationError": {
|
||||
"description": "Validation error for invalid YouTube bitrate input",
|
||||
"placeholders": {
|
||||
"min": {
|
||||
"type": "int"
|
||||
},
|
||||
"max": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
||||
"@downloadAskBeforeDownload": {
|
||||
"description": "Setting - show quality picker"
|
||||
},
|
||||
"downloadDirectory": "Direktori Unduhan",
|
||||
"@downloadDirectory": {
|
||||
"description": "Setting - download folder"
|
||||
@@ -3869,4 +3928,4 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"@historyFilterSingles": {
|
||||
"description": "Filter chip - show singles only"
|
||||
},
|
||||
"historyTracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
|
||||
"historyTracksCount": "{count, plural, one {1 faixa} other{{count} faixas}}",
|
||||
"@historyTracksCount": {
|
||||
"description": "Track count with plural form",
|
||||
"placeholders": {
|
||||
@@ -98,7 +98,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbuns}}",
|
||||
"historyAlbumsCount": "{count, plural, one {1 álbum} other{{count} álbuns}}",
|
||||
"@historyAlbumsCount": {
|
||||
"description": "Album count with plural form",
|
||||
"placeholders": {
|
||||
@@ -636,7 +636,7 @@
|
||||
"@albumTitle": {
|
||||
"description": "Album screen title"
|
||||
},
|
||||
"albumTracks": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
|
||||
"albumTracks": "{count, plural, one {1 faixa} other{{count} faixas}}",
|
||||
"@albumTracks": {
|
||||
"description": "Album track count",
|
||||
"placeholders": {
|
||||
@@ -673,7 +673,7 @@
|
||||
"@artistCompilations": {
|
||||
"description": "Section header for compilations"
|
||||
},
|
||||
"artistReleases": "{count, plural, one {}=1{1 lançamento} other{{count} lançamentos}}",
|
||||
"artistReleases": "{count, plural, one {1 lançamento} other{{count} lançamentos}}",
|
||||
"@artistReleases": {
|
||||
"description": "Artist release count",
|
||||
"placeholders": {
|
||||
@@ -1152,7 +1152,7 @@
|
||||
"@dialogDeleteSelectedTitle": {
|
||||
"description": "Dialog title - delete selected items"
|
||||
},
|
||||
"dialogDeleteSelectedMessage": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}} do histórico?\n\nIsso também apagará os arquivos do armazenamento.",
|
||||
"dialogDeleteSelectedMessage": "Apagar {count} {count, plural, one {faixa} other{faixas}} do histórico?\n\nIsso também apagará os arquivos do armazenamento.",
|
||||
"@dialogDeleteSelectedMessage": {
|
||||
"description": "Dialog message - delete selected tracks",
|
||||
"placeholders": {
|
||||
@@ -1231,7 +1231,7 @@
|
||||
"@snackbarCredentialsCleared": {
|
||||
"description": "Snackbar - Spotify credentials removed"
|
||||
},
|
||||
"snackbarDeletedTracks": "{count} {count, plural, one {}=1{faixa apagada} other{faixas apagadas}}",
|
||||
"snackbarDeletedTracks": "{count} {count, plural, one {faixa apagada} other{faixas apagadas}}",
|
||||
"@snackbarDeletedTracks": {
|
||||
"description": "Snackbar - tracks deleted",
|
||||
"placeholders": {
|
||||
@@ -1438,7 +1438,7 @@
|
||||
"@selectionTapToSelect": {
|
||||
"description": "Hint - how to select items"
|
||||
},
|
||||
"selectionDeleteTracks": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}}",
|
||||
"selectionDeleteTracks": "Apagar {count} {count, plural, one {faixa} other{faixas}}",
|
||||
"@selectionDeleteTracks": {
|
||||
"description": "Delete button with count",
|
||||
"placeholders": {
|
||||
@@ -2014,7 +2014,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
|
||||
"tracksCount": "{count, plural, one {1 faixa} other{{count} faixas}}",
|
||||
"@tracksCount": {
|
||||
"description": "Track count display",
|
||||
"placeholders": {
|
||||
@@ -2758,7 +2758,7 @@
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
},
|
||||
"downloadedAlbumDeleteMessage": "Excluir {count} {count, plural, one {}=1{faixa} other{faixas}} deste álbum?\n\nIsso também excluirá os arquivos do armazenamento.",
|
||||
"downloadedAlbumDeleteMessage": "Excluir {count} {count, plural, one {faixa} other{faixas}} deste álbum?\n\nIsso também excluirá os arquivos do armazenamento.",
|
||||
"@downloadedAlbumDeleteMessage": {
|
||||
"description": "Delete confirmation with count",
|
||||
"placeholders": {
|
||||
@@ -2797,7 +2797,7 @@
|
||||
"@downloadedAlbumTapToSelect": {
|
||||
"description": "Selection hint"
|
||||
},
|
||||
"downloadedAlbumDeleteCount": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}}",
|
||||
"downloadedAlbumDeleteCount": "Apagar {count} {count, plural, one {faixa} other{faixas}}",
|
||||
"@downloadedAlbumDeleteCount": {
|
||||
"description": "Delete button text with count",
|
||||
"placeholders": {
|
||||
|
||||
@@ -3114,7 +3114,7 @@
|
||||
"@libraryAboutDescription": {
|
||||
"description": "Description of local library feature"
|
||||
},
|
||||
"libraryTracksCount": "{count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
|
||||
"libraryTracksCount": "{count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
||||
"@libraryTracksCount": {
|
||||
"description": "Track count in library",
|
||||
"placeholders": {
|
||||
@@ -3156,7 +3156,7 @@
|
||||
"@libraryInLibrary": {
|
||||
"description": "Badge shown on tracks that exist in local library"
|
||||
},
|
||||
"libraryRemovedMissingFiles": "Удалено {count} {count, plural, one {отсутствующий файл} few {трека} many {отсутствующих файлов} =1{отсутствующий файл} other{отсутствующих файлов}} в библиотеке",
|
||||
"libraryRemovedMissingFiles": "Удалено {count} {count, plural, one {отсутствующий файл} few {трека} many {отсутствующих файлов} other{отсутствующих файлов}} в библиотеке",
|
||||
"@libraryRemovedMissingFiles": {
|
||||
"description": "Snackbar after cleanup",
|
||||
"placeholders": {
|
||||
@@ -3282,7 +3282,7 @@
|
||||
"@timeJustNow": {
|
||||
"description": "Relative time - less than a minute ago"
|
||||
},
|
||||
"timeMinutesAgo": "{count, plural, one {{count} минуту} few {{count} минуты} many {{count} минут} =1 {1 минуту} other {{count} минут}} назад",
|
||||
"timeMinutesAgo": "{count, plural, one {{count} минуту} few {{count} минуты} many {{count} минут} other {{count} минут}} назад",
|
||||
"@timeMinutesAgo": {
|
||||
"description": "Relative time - minutes ago",
|
||||
"placeholders": {
|
||||
@@ -3291,7 +3291,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"timeHoursAgo": "{count, plural, one {{count} час} few {{count} часа} many {{count} часов} =1 {1 час} other {{count} часов}} назад",
|
||||
"timeHoursAgo": "{count, plural, one {{count} час} few {{count} часа} many {{count} часов} other {{count} часов}} назад",
|
||||
"@timeHoursAgo": {
|
||||
"description": "Relative time - hours ago",
|
||||
"placeholders": {
|
||||
@@ -3324,7 +3324,7 @@
|
||||
"@storageSwitchExistingDownloads": {
|
||||
"description": "Section header for existing downloads info"
|
||||
},
|
||||
"storageSwitchExistingDownloadsInfo": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}} в {mode} хранилище",
|
||||
"storageSwitchExistingDownloadsInfo": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}} в {mode} хранилище",
|
||||
"@storageSwitchExistingDownloadsInfo": {
|
||||
"description": "Info about existing downloads count",
|
||||
"placeholders": {
|
||||
@@ -3378,7 +3378,7 @@
|
||||
"@storageStatsTitle": {
|
||||
"description": "Section title for storage stats"
|
||||
},
|
||||
"storageStatsAppCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}} в хранилище приложения",
|
||||
"storageStatsAppCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}} в хранилище приложения",
|
||||
"@storageStatsAppCount": {
|
||||
"description": "Count of tracks in app storage",
|
||||
"placeholders": {
|
||||
@@ -3387,7 +3387,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"storageStatsSafCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}} в вашей папке в SAF",
|
||||
"storageStatsSafCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}} в вашей папке в SAF",
|
||||
"@storageStatsSafCount": {
|
||||
"description": "Count of tracks in SAF storage",
|
||||
"placeholders": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -11,19 +12,68 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
_configureImageCache();
|
||||
final runtimeProfile = await _resolveRuntimeProfile();
|
||||
_configureImageCache(runtimeProfile);
|
||||
|
||||
runApp(
|
||||
ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
|
||||
ProviderScope(
|
||||
child: _EagerInitialization(
|
||||
child: SpotiFLACApp(
|
||||
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _configureImageCache() {
|
||||
Future<_RuntimeProfile> _resolveRuntimeProfile() async {
|
||||
const defaults = _RuntimeProfile(
|
||||
imageCacheMaximumSize: 240,
|
||||
imageCacheMaximumSizeBytes: 60 << 20,
|
||||
disableOverscrollEffects: false,
|
||||
);
|
||||
|
||||
if (!Platform.isAndroid) return defaults;
|
||||
|
||||
try {
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
final isArm32Only = androidInfo.supported64BitAbis.isEmpty;
|
||||
final isLowRamDevice =
|
||||
androidInfo.isLowRamDevice || androidInfo.physicalRamSize <= 2500;
|
||||
|
||||
if (!isArm32Only && !isLowRamDevice) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
return _RuntimeProfile(
|
||||
imageCacheMaximumSize: 120,
|
||||
imageCacheMaximumSizeBytes: 24 << 20,
|
||||
disableOverscrollEffects: true,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to resolve runtime profile: $e');
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
void _configureImageCache(_RuntimeProfile runtimeProfile) {
|
||||
final imageCache = PaintingBinding.instance.imageCache;
|
||||
// Keep memory cache bounded so cover-heavy pages don't retain too many
|
||||
// full-resolution images simultaneously.
|
||||
imageCache.maximumSize = 240;
|
||||
imageCache.maximumSizeBytes = 60 << 20; // 60 MiB
|
||||
imageCache.maximumSize = runtimeProfile.imageCacheMaximumSize;
|
||||
imageCache.maximumSizeBytes = runtimeProfile.imageCacheMaximumSizeBytes;
|
||||
}
|
||||
|
||||
class _RuntimeProfile {
|
||||
final int imageCacheMaximumSize;
|
||||
final int imageCacheMaximumSizeBytes;
|
||||
final bool disableOverscrollEffects;
|
||||
|
||||
const _RuntimeProfile({
|
||||
required this.imageCacheMaximumSize,
|
||||
required this.imageCacheMaximumSizeBytes,
|
||||
required this.disableOverscrollEffects,
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget to eagerly initialize providers that need to load data on startup
|
||||
|
||||
@@ -39,6 +39,10 @@ class AppSettings {
|
||||
final String lyricsMode;
|
||||
final String
|
||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||
final int
|
||||
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps)
|
||||
final int
|
||||
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
|
||||
final bool
|
||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||
final bool
|
||||
@@ -56,6 +60,18 @@ class AppSettings {
|
||||
final bool
|
||||
hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||
|
||||
// Lyrics Provider Settings
|
||||
final List<String>
|
||||
lyricsProviders; // Ordered list of enabled lyrics provider IDs
|
||||
final bool
|
||||
lyricsIncludeTranslationNetease; // Append translated lyrics (Netease)
|
||||
final bool
|
||||
lyricsIncludeRomanizationNetease; // Append romanized lyrics (Netease)
|
||||
final bool
|
||||
lyricsMultiPersonWordByWord; // Enable v1/v2 + [bg:] tags for Apple/QQ syllable lyrics
|
||||
final String
|
||||
musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
this.audioQuality = 'LOSSLESS',
|
||||
@@ -91,6 +107,8 @@ class AppSettings {
|
||||
this.locale = 'system',
|
||||
this.lyricsMode = 'embed',
|
||||
this.tidalHighFormat = 'mp3_320',
|
||||
this.youtubeOpusBitrate = 256,
|
||||
this.youtubeMp3Bitrate = 320,
|
||||
this.useAllFilesAccess = false,
|
||||
this.autoExportFailedDownloads = false,
|
||||
this.downloadNetworkMode = 'any',
|
||||
@@ -100,6 +118,18 @@ class AppSettings {
|
||||
this.localLibraryShowDuplicates = true,
|
||||
// Tutorial default
|
||||
this.hasCompletedTutorial = false,
|
||||
// Lyrics providers default order
|
||||
this.lyricsProviders = const [
|
||||
'lrclib',
|
||||
'musixmatch',
|
||||
'netease',
|
||||
'apple_music',
|
||||
'qqmusic',
|
||||
],
|
||||
this.lyricsIncludeTranslationNetease = false,
|
||||
this.lyricsIncludeRomanizationNetease = false,
|
||||
this.lyricsMultiPersonWordByWord = false,
|
||||
this.musixmatchLanguage = '',
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -138,6 +168,8 @@ class AppSettings {
|
||||
String? locale,
|
||||
String? lyricsMode,
|
||||
String? tidalHighFormat,
|
||||
int? youtubeOpusBitrate,
|
||||
int? youtubeMp3Bitrate,
|
||||
bool? useAllFilesAccess,
|
||||
bool? autoExportFailedDownloads,
|
||||
String? downloadNetworkMode,
|
||||
@@ -147,6 +179,12 @@ class AppSettings {
|
||||
bool? localLibraryShowDuplicates,
|
||||
// Tutorial
|
||||
bool? hasCompletedTutorial,
|
||||
// Lyrics providers
|
||||
List<String>? lyricsProviders,
|
||||
bool? lyricsIncludeTranslationNetease,
|
||||
bool? lyricsIncludeRomanizationNetease,
|
||||
bool? lyricsMultiPersonWordByWord,
|
||||
String? musixmatchLanguage,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -191,6 +229,8 @@ class AppSettings {
|
||||
locale: locale ?? this.locale,
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
|
||||
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
|
||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||
autoExportFailedDownloads:
|
||||
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||
@@ -202,6 +242,17 @@ class AppSettings {
|
||||
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||
// Tutorial
|
||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
||||
// Lyrics providers
|
||||
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
||||
lyricsIncludeTranslationNetease:
|
||||
lyricsIncludeTranslationNetease ??
|
||||
this.lyricsIncludeTranslationNetease,
|
||||
lyricsIncludeRomanizationNetease:
|
||||
lyricsIncludeRomanizationNetease ??
|
||||
this.lyricsIncludeRomanizationNetease,
|
||||
lyricsMultiPersonWordByWord:
|
||||
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
|
||||
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
|
||||
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
|
||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||
autoExportFailedDownloads:
|
||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||
@@ -53,50 +55,70 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
localLibraryShowDuplicates:
|
||||
json['localLibraryShowDuplicates'] as bool? ?? true,
|
||||
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
||||
lyricsProviders:
|
||||
(json['lyricsProviders'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'],
|
||||
lyricsIncludeTranslationNetease:
|
||||
json['lyricsIncludeTranslationNetease'] as bool? ?? false,
|
||||
lyricsIncludeRomanizationNetease:
|
||||
json['lyricsIncludeRomanizationNetease'] as bool? ?? false,
|
||||
lyricsMultiPersonWordByWord:
|
||||
json['lyricsMultiPersonWordByWord'] as bool? ?? false,
|
||||
musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
<String, dynamic>{
|
||||
'defaultService': instance.defaultService,
|
||||
'audioQuality': instance.audioQuality,
|
||||
'filenameFormat': instance.filenameFormat,
|
||||
'downloadDirectory': instance.downloadDirectory,
|
||||
'storageMode': instance.storageMode,
|
||||
'downloadTreeUri': instance.downloadTreeUri,
|
||||
'autoFallback': instance.autoFallback,
|
||||
'embedLyrics': instance.embedLyrics,
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'concurrentDownloads': instance.concurrentDownloads,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
'updateChannel': instance.updateChannel,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
||||
'filterContributingArtistsInAlbumArtist':
|
||||
instance.filterContributingArtistsInAlbumArtist,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'historyFilterMode': instance.historyFilterMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
'spotifyClientId': instance.spotifyClientId,
|
||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||
'metadataSource': instance.metadataSource,
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
'locale': instance.locale,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'tidalHighFormat': instance.tidalHighFormat,
|
||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||
'localLibraryPath': instance.localLibraryPath,
|
||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||
};
|
||||
Map<String, dynamic> _$AppSettingsToJson(
|
||||
AppSettings instance,
|
||||
) => <String, dynamic>{
|
||||
'defaultService': instance.defaultService,
|
||||
'audioQuality': instance.audioQuality,
|
||||
'filenameFormat': instance.filenameFormat,
|
||||
'downloadDirectory': instance.downloadDirectory,
|
||||
'storageMode': instance.storageMode,
|
||||
'downloadTreeUri': instance.downloadTreeUri,
|
||||
'autoFallback': instance.autoFallback,
|
||||
'embedLyrics': instance.embedLyrics,
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'concurrentDownloads': instance.concurrentDownloads,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
'updateChannel': instance.updateChannel,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
||||
'filterContributingArtistsInAlbumArtist':
|
||||
instance.filterContributingArtistsInAlbumArtist,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'historyFilterMode': instance.historyFilterMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
'spotifyClientId': instance.spotifyClientId,
|
||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||
'metadataSource': instance.metadataSource,
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
'locale': instance.locale,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'tidalHighFormat': instance.tidalHighFormat,
|
||||
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
|
||||
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
|
||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||
'localLibraryPath': instance.localLibraryPath,
|
||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||
'lyricsProviders': instance.lyricsProviders,
|
||||
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
|
||||
'lyricsIncludeRomanizationNetease': instance.lyricsIncludeRomanizationNetease,
|
||||
'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord,
|
||||
'musixmatchLanguage': instance.musixmatchLanguage,
|
||||
};
|
||||
|
||||
@@ -1304,7 +1304,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
static final _featuredArtistPattern = RegExp(
|
||||
r'\s*[,;&]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+',
|
||||
r'\s*[,;]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
@@ -2771,7 +2771,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
settings,
|
||||
);
|
||||
|
||||
final quality = item.qualityOverride ?? state.audioQuality;
|
||||
var quality = item.qualityOverride ?? state.audioQuality;
|
||||
if (item.service.toLowerCase() == 'youtube') {
|
||||
final normalized = quality.toLowerCase();
|
||||
final isYoutubeQuality =
|
||||
normalized.startsWith('mp3_') || normalized.startsWith('opus_');
|
||||
if (!isYoutubeQuality) {
|
||||
final mp3Bitrate = (() {
|
||||
const supported = [128, 256, 320];
|
||||
var nearest = supported.first;
|
||||
var nearestDistance = (settings.youtubeMp3Bitrate - nearest).abs();
|
||||
for (final option in supported.skip(1)) {
|
||||
final distance = (settings.youtubeMp3Bitrate - option).abs();
|
||||
if (distance < nearestDistance ||
|
||||
(distance == nearestDistance && option > nearest)) {
|
||||
nearest = option;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
})();
|
||||
quality = 'mp3_$mp3Bitrate';
|
||||
}
|
||||
}
|
||||
final isSafMode = _isSafMode(settings);
|
||||
final relativeOutputDir = isSafMode
|
||||
? await _buildRelativeOutputDir(
|
||||
@@ -2813,6 +2835,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'track': trackToDownload.trackNumber ?? 0,
|
||||
'disc': trackToDownload.discNumber ?? 0,
|
||||
'year': _extractYear(trackToDownload.releaseDate) ?? '',
|
||||
'date': trackToDownload.releaseDate ?? '',
|
||||
});
|
||||
final sanitized = await PlatformBridge.sanitizeFilename(baseName);
|
||||
safBaseName = sanitized;
|
||||
@@ -3031,8 +3054,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
quality: quality,
|
||||
// Keep prior behavior: non-YouTube paths were implicitly true.
|
||||
embedLyrics: isYouTube ? settings.embedLyrics : true,
|
||||
embedLyrics: settings.embedLyrics,
|
||||
embedMaxQualityCover: settings.maxQualityCover,
|
||||
trackNumber: normalizedTrackNumber,
|
||||
discNumber: normalizedDiscNumber,
|
||||
|
||||
@@ -26,6 +26,7 @@ class Extension {
|
||||
final List<QualityOption> qualityOptions;
|
||||
final bool hasMetadataProvider;
|
||||
final bool hasDownloadProvider;
|
||||
final bool hasLyricsProvider;
|
||||
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||
final SearchBehavior? searchBehavior;
|
||||
final URLHandler? urlHandler;
|
||||
@@ -49,6 +50,7 @@ class Extension {
|
||||
this.qualityOptions = const [],
|
||||
this.hasMetadataProvider = false,
|
||||
this.hasDownloadProvider = false,
|
||||
this.hasLyricsProvider = false,
|
||||
this.skipMetadataEnrichment = false,
|
||||
this.searchBehavior,
|
||||
this.urlHandler,
|
||||
@@ -78,6 +80,7 @@ class Extension {
|
||||
.toList() ?? [],
|
||||
hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false,
|
||||
hasDownloadProvider: json['has_download_provider'] as bool? ?? false,
|
||||
hasLyricsProvider: json['has_lyrics_provider'] as bool? ?? false,
|
||||
skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false,
|
||||
searchBehavior: json['search_behavior'] != null
|
||||
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
|
||||
@@ -111,6 +114,7 @@ class Extension {
|
||||
List<QualityOption>? qualityOptions,
|
||||
bool? hasMetadataProvider,
|
||||
bool? hasDownloadProvider,
|
||||
bool? hasLyricsProvider,
|
||||
bool? skipMetadataEnrichment,
|
||||
SearchBehavior? searchBehavior,
|
||||
URLHandler? urlHandler,
|
||||
@@ -134,6 +138,7 @@ class Extension {
|
||||
qualityOptions: qualityOptions ?? this.qualityOptions,
|
||||
hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider,
|
||||
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
|
||||
hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider,
|
||||
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
||||
searchBehavior: searchBehavior ?? this.searchBehavior,
|
||||
urlHandler: urlHandler ?? this.urlHandler,
|
||||
|
||||
@@ -13,6 +13,9 @@ const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||
final _log = AppLogger('SettingsProvider');
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
|
||||
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
||||
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
bool _isSavingSettings = false;
|
||||
@@ -32,6 +35,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = AppSettings.fromJson(jsonDecode(json));
|
||||
|
||||
await _runMigrations(prefs);
|
||||
await _normalizeYouTubeBitratesIfNeeded();
|
||||
}
|
||||
|
||||
await _loadSpotifyClientSecret(prefs);
|
||||
@@ -39,6 +43,23 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_applySpotifyCredentials();
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
|
||||
_syncLyricsSettingsToBackend();
|
||||
}
|
||||
|
||||
void _syncLyricsSettingsToBackend() {
|
||||
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) {
|
||||
_log.w('Failed to sync lyrics providers to backend: $e');
|
||||
});
|
||||
|
||||
PlatformBridge.setLyricsFetchOptions({
|
||||
'include_translation_netease': state.lyricsIncludeTranslationNetease,
|
||||
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
|
||||
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
|
||||
'musixmatch_language': state.musixmatchLanguage,
|
||||
}).catchError((e) {
|
||||
_log.w('Failed to sync lyrics fetch options to backend: $e');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||
@@ -90,6 +111,49 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
int _nearestSupportedBitrate(int value, List<int> supported) {
|
||||
var nearest = supported.first;
|
||||
var nearestDistance = (value - nearest).abs();
|
||||
|
||||
for (final option in supported.skip(1)) {
|
||||
final distance = (value - option).abs();
|
||||
// On tie, prefer higher quality bitrate.
|
||||
if (distance < nearestDistance ||
|
||||
(distance == nearestDistance && option > nearest)) {
|
||||
nearest = option;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
int _normalizeYouTubeOpusBitrate(int bitrate) {
|
||||
return _nearestSupportedBitrate(bitrate, _youtubeOpusSupportedBitrates);
|
||||
}
|
||||
|
||||
int _normalizeYouTubeMp3Bitrate(int bitrate) {
|
||||
return _nearestSupportedBitrate(bitrate, _youtubeMp3SupportedBitrates);
|
||||
}
|
||||
|
||||
Future<void> _normalizeYouTubeBitratesIfNeeded() async {
|
||||
final normalizedOpus = _normalizeYouTubeOpusBitrate(
|
||||
state.youtubeOpusBitrate,
|
||||
);
|
||||
final normalizedMp3 = _normalizeYouTubeMp3Bitrate(state.youtubeMp3Bitrate);
|
||||
|
||||
if (normalizedOpus == state.youtubeOpusBitrate &&
|
||||
normalizedMp3 == state.youtubeMp3Bitrate) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
youtubeOpusBitrate: normalizedOpus,
|
||||
youtubeMp3Bitrate: normalizedMp3,
|
||||
);
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
|
||||
final storedSecret = await _secureStorage.read(
|
||||
key: _spotifyClientSecretKey,
|
||||
@@ -188,6 +252,38 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
void setLyricsProviders(List<String> providers) {
|
||||
state = state.copyWith(lyricsProviders: providers);
|
||||
_saveSettings();
|
||||
_syncLyricsSettingsToBackend();
|
||||
}
|
||||
|
||||
void setLyricsIncludeTranslationNetease(bool enabled) {
|
||||
state = state.copyWith(lyricsIncludeTranslationNetease: enabled);
|
||||
_saveSettings();
|
||||
_syncLyricsSettingsToBackend();
|
||||
}
|
||||
|
||||
void setLyricsIncludeRomanizationNetease(bool enabled) {
|
||||
state = state.copyWith(lyricsIncludeRomanizationNetease: enabled);
|
||||
_saveSettings();
|
||||
_syncLyricsSettingsToBackend();
|
||||
}
|
||||
|
||||
void setLyricsMultiPersonWordByWord(bool enabled) {
|
||||
state = state.copyWith(lyricsMultiPersonWordByWord: enabled);
|
||||
_saveSettings();
|
||||
_syncLyricsSettingsToBackend();
|
||||
}
|
||||
|
||||
void setMusixmatchLanguage(String languageCode) {
|
||||
state = state.copyWith(
|
||||
musixmatchLanguage: languageCode.trim().toLowerCase(),
|
||||
);
|
||||
_saveSettings();
|
||||
_syncLyricsSettingsToBackend();
|
||||
}
|
||||
|
||||
void setMaxQualityCover(bool enabled) {
|
||||
state = state.copyWith(maxQualityCover: enabled);
|
||||
_saveSettings();
|
||||
@@ -343,6 +439,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setYoutubeOpusBitrate(int bitrate) {
|
||||
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
|
||||
state = state.copyWith(youtubeOpusBitrate: normalized);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setYoutubeMp3Bitrate(int bitrate) {
|
||||
final normalized = _normalizeYouTubeMp3Bitrate(bitrate);
|
||||
state = state.copyWith(youtubeMp3Bitrate: normalized);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAllFilesAccess(bool enabled) {
|
||||
state = state.copyWith(useAllFilesAccess: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -490,6 +490,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
0,
|
||||
(sum, a) => sum + a.totalTracks,
|
||||
);
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
|
||||
final compactLayout =
|
||||
MediaQuery.sizeOf(context).width < 430 || textScale > 1.15;
|
||||
|
||||
return Positioned(
|
||||
left: 0,
|
||||
@@ -510,53 +513,145 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: context.l10n.dialogCancel,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: compactLayout
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.discographySelectedCount(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: context.l10n.dialogCancel,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.discographySelectedCount(
|
||||
selectedCount,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (selectedCount > 0)
|
||||
Text(
|
||||
context.l10n.tracksCount(totalTracks),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (selectedCount > 0)
|
||||
Text(
|
||||
context.l10n.tracksCount(totalTracks),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: allSelected
|
||||
? _deselectAll
|
||||
: () => _selectAll(allAlbums),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
allSelected
|
||||
? context.l10n.actionDeselect
|
||||
: context.l10n.actionSelectAll,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: selectedCount > 0
|
||||
? () => _downloadSelectedAlbums(
|
||||
context,
|
||||
selectedAlbums,
|
||||
)
|
||||
: null,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
context.l10n.discographyDownloadSelected,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: context.l10n.dialogCancel,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.discographySelectedCount(
|
||||
selectedCount,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (selectedCount > 0)
|
||||
Text(
|
||||
context.l10n.tracksCount(totalTracks),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: allSelected
|
||||
? _deselectAll
|
||||
: () => _selectAll(allAlbums),
|
||||
child: Text(
|
||||
allSelected
|
||||
? context.l10n.actionDeselect
|
||||
: context.l10n.actionSelectAll,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: selectedCount > 0
|
||||
? () => _downloadSelectedAlbums(
|
||||
context,
|
||||
selectedAlbums,
|
||||
)
|
||||
: null,
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.discographyDownloadSelected),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: allSelected
|
||||
? _deselectAll
|
||||
: () => _selectAll(allAlbums),
|
||||
child: Text(
|
||||
allSelected
|
||||
? context.l10n.actionDeselect
|
||||
: context.l10n.actionSelectAll,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: selectedCount > 0
|
||||
? () => _downloadSelectedAlbums(context, selectedAlbums)
|
||||
: null,
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: Text(context.l10n.discographyDownloadSelected),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1427,15 +1522,31 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
void _downloadTrack(Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
void enqueue(String service, {String? quality}) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
onSelect: (quality, service) {
|
||||
if (!mounted) return;
|
||||
enqueue(service, quality: quality);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
enqueue(settings.defaultService);
|
||||
}
|
||||
|
||||
Widget _buildAlbumSection(
|
||||
@@ -1468,7 +1579,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
final album = albums[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(album.id),
|
||||
child: _buildAlbumCard(album, colorScheme, tileSize: tileSize, sectionHeight: sectionHeight),
|
||||
child: _buildAlbumCard(
|
||||
album,
|
||||
colorScheme,
|
||||
tileSize: tileSize,
|
||||
sectionHeight: sectionHeight,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -1601,9 +1717,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
Flexible(
|
||||
child: Text(
|
||||
album.name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
@@ -141,6 +141,14 @@ class AboutPage extends StatelessWidget {
|
||||
title: context.l10n.aboutSpotiSaver,
|
||||
subtitle: context.l10n.aboutSpotiSaverDesc,
|
||||
onTap: () => _launchUrl('https://spotisaver.net'),
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: 'Paxsenix',
|
||||
subtitle:
|
||||
'Partner lyrics proxy for Apple Music and QQ Music sources',
|
||||
onTap: () => _launchUrl('https://lyrics.paxsenix.org'),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -68,7 +68,9 @@ class DonatePage extends StatelessWidget {
|
||||
// Combined notice card
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
|
||||
color: colorScheme.secondaryContainer.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
@@ -98,7 +100,8 @@ class DonatePage extends StatelessWidget {
|
||||
const SizedBox(height: 10),
|
||||
_NoticeLine(
|
||||
icon: Icons.block,
|
||||
text: 'Not selling early access, premium features, or paywalls',
|
||||
text:
|
||||
'Not selling early access, premium features, or paywalls',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -110,36 +113,40 @@ class DonatePage extends StatelessWidget {
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.favorite_border,
|
||||
text: 'Your support is the only way to keep this project alive',
|
||||
text:
|
||||
'Your support is the only way to keep this project alive',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
Divider(
|
||||
height: 24,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
color: colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
_NoticeLine(
|
||||
icon: Icons.history,
|
||||
text: 'Your name stays permanently in every version it was included in',
|
||||
text:
|
||||
'Your name stays permanently in every version it was included in',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.update,
|
||||
text: 'Supporter list is updated monthly and embedded in the app',
|
||||
text:
|
||||
'Supporter list is updated monthly and embedded in the app',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_NoticeLine(
|
||||
icon: Icons.cloud_off,
|
||||
text: 'No remote server -- everything is stored locally',
|
||||
text:
|
||||
'No remote server -- everything is stored locally',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -158,6 +165,16 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
const donorNames = [
|
||||
'J',
|
||||
'Julian',
|
||||
'matt_3050',
|
||||
'Daniel',
|
||||
'283Fabio',
|
||||
'laflame',
|
||||
'Elias el Autentico',
|
||||
'Faylyne',
|
||||
];
|
||||
|
||||
// Match SettingsGroup color logic
|
||||
final cardColor = isDark
|
||||
@@ -200,15 +217,15 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DonorTile(name: 'J', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'Julian', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
|
||||
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
|
||||
_DonorTile(name: '283Fabio', colorScheme: colorScheme),
|
||||
_DonorTile(
|
||||
name: 'Elias el Autentico',
|
||||
colorScheme: colorScheme,
|
||||
showDivider: false,
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: donorNames
|
||||
.map(
|
||||
(name) =>
|
||||
_SupporterChip(name: name, colorScheme: colorScheme),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -340,55 +357,45 @@ class _DonateCardItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DonorTile extends StatelessWidget {
|
||||
class _SupporterChip extends StatelessWidget {
|
||||
final String name;
|
||||
final ColorScheme colorScheme;
|
||||
final bool showDivider;
|
||||
|
||||
const _DonorTile({
|
||||
required this.name,
|
||||
required this.colorScheme,
|
||||
this.showDivider = true,
|
||||
});
|
||||
const _SupporterChip({required this.name, required this.colorScheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
return Material(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -414,9 +421,9 @@ class _NoticeLine extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
@@ -260,6 +261,33 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
),
|
||||
),
|
||||
],
|
||||
SettingsItem(
|
||||
title: context.l10n.youtubeOpusBitrateTitle,
|
||||
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256)',
|
||||
onTap: () => _showYoutubeBitratePicker(
|
||||
context: context,
|
||||
title: context.l10n.youtubeOpusBitrateTitle,
|
||||
currentValue: settings.youtubeOpusBitrate,
|
||||
options: const [128, 256],
|
||||
onSave: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setYoutubeOpusBitrate(value),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
title: context.l10n.youtubeMp3BitrateTitle,
|
||||
subtitle: '${settings.youtubeMp3Bitrate}kbps (128/256/320)',
|
||||
onTap: () => _showYoutubeBitratePicker(
|
||||
context: context,
|
||||
title: context.l10n.youtubeMp3BitrateTitle,
|
||||
currentValue: settings.youtubeMp3Bitrate,
|
||||
options: const [128, 256, 320],
|
||||
onSave: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setYoutubeMp3Bitrate(value),
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -270,17 +298,88 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.lyricsMode,
|
||||
subtitle: _getLyricsModeLabel(context, settings.lyricsMode),
|
||||
onTap: () => _showLyricsModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
showDivider: false,
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.subtitles_outlined,
|
||||
title: context.l10n.optionsEmbedLyrics,
|
||||
subtitle: context.l10n.optionsEmbedLyricsSubtitle,
|
||||
value: settings.embedLyrics,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setEmbedLyrics(value),
|
||||
showDivider: settings.embedLyrics,
|
||||
),
|
||||
if (settings.embedLyrics) ...[
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.lyricsMode,
|
||||
subtitle:
|
||||
_getLyricsModeLabel(context, settings.lyricsMode),
|
||||
onTap: () => _showLyricsModePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.source_outlined,
|
||||
title: 'Lyrics Providers',
|
||||
subtitle: _getLyricsProvidersSubtitle(
|
||||
settings.lyricsProviders,
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LyricsProviderPriorityPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.translate_outlined,
|
||||
title: 'Netease: Include Translation',
|
||||
subtitle: settings.lyricsIncludeTranslationNetease
|
||||
? 'Append translated lyrics when available'
|
||||
: 'Use original lyrics only',
|
||||
value: settings.lyricsIncludeTranslationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsIncludeTranslationNetease(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.text_fields_outlined,
|
||||
title: 'Netease: Include Romanization',
|
||||
subtitle: settings.lyricsIncludeRomanizationNetease
|
||||
? 'Append romanized lyrics when available'
|
||||
: 'Disabled',
|
||||
value: settings.lyricsIncludeRomanizationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsIncludeRomanizationNetease(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.record_voice_over_outlined,
|
||||
title: 'Apple/QQ Multi-Person Word-by-Word',
|
||||
subtitle: settings.lyricsMultiPersonWordByWord
|
||||
? 'Enable v1/v2 speaker and [bg:] tags'
|
||||
: 'Simplified word-by-word formatting',
|
||||
value: settings.lyricsMultiPersonWordByWord,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsMultiPersonWordByWord(value),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.language_outlined,
|
||||
title: 'Musixmatch Language',
|
||||
subtitle: settings.musixmatchLanguage.isEmpty
|
||||
? 'Auto (original)'
|
||||
: settings.musixmatchLanguage.toUpperCase(),
|
||||
onTap: () => _showMusixmatchLanguagePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.musixmatchLanguage,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -620,14 +719,28 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final controller = TextEditingController(text: current);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final tags = [
|
||||
final basicTags = [
|
||||
'{artist}',
|
||||
'{title}',
|
||||
'{album}',
|
||||
'{track}',
|
||||
'{year}',
|
||||
'{date}',
|
||||
'{disc}',
|
||||
];
|
||||
final advancedTags = [
|
||||
'{track_raw}',
|
||||
'{track:02}',
|
||||
'{track:1}',
|
||||
'{date:%Y}',
|
||||
'{date:%Y-%m-%d}',
|
||||
'{disc_raw}',
|
||||
'{disc:02}',
|
||||
];
|
||||
var showAdvancedTags = RegExp(
|
||||
r'\{(?:track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}',
|
||||
caseSensitive: false,
|
||||
).hasMatch(current);
|
||||
|
||||
void insertTag(String tag) {
|
||||
final text = controller.text;
|
||||
@@ -659,130 +772,164 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.filenameFormat,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Customize how your files are named.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: '{artist} - {title}',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Text(
|
||||
'Tap to insert tag:',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: tags.map((tag) {
|
||||
return ActionChip(
|
||||
label: Text(tag),
|
||||
onPressed: () => insertTag(tag),
|
||||
backgroundColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.filenameFormat,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Customize how your files are named.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: '{artist} - {title}',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
Text(
|
||||
'Tap to insert tag:',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: basicTags.map((tag) {
|
||||
return ActionChip(
|
||||
label: Text(tag),
|
||||
onPressed: () => insertTag(tag),
|
||||
backgroundColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFilenameFormat(controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
value: showAdvancedTags,
|
||||
onChanged: (value) =>
|
||||
setModalState(() => showAdvancedTags = value),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(context.l10n.filenameShowAdvancedTags),
|
||||
subtitle: Text(
|
||||
context.l10n.filenameShowAdvancedTagsDescription,
|
||||
),
|
||||
),
|
||||
if (showAdvancedTags) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: advancedTags.map((tag) {
|
||||
return ActionChip(
|
||||
label: Text(tag),
|
||||
onPressed: () => insertTag(tag),
|
||||
backgroundColor: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFilenameFormat(controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1135,6 +1282,172 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
static const _providerDisplayNames = <String, String>{
|
||||
'lrclib': 'LRCLIB',
|
||||
'netease': 'Netease',
|
||||
'musixmatch': 'Musixmatch',
|
||||
'apple_music': 'Apple Music',
|
||||
'qqmusic': 'QQ Music',
|
||||
};
|
||||
|
||||
String _getLyricsProvidersSubtitle(List<String> providers) {
|
||||
if (providers.isEmpty) return 'None enabled';
|
||||
return providers.map((p) => _providerDisplayNames[p] ?? p).join(' > ');
|
||||
}
|
||||
|
||||
String _normalizeMusixmatchLanguage(String value) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), '');
|
||||
}
|
||||
|
||||
void _showYoutubeBitratePicker({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required int currentValue,
|
||||
required List<int> options,
|
||||
required void Function(int value) onSave,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
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: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(sheetContext).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final bitrate in options)
|
||||
ListTile(
|
||||
title: Text('$bitrate kbps'),
|
||||
trailing: bitrate == currentValue
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
onSave(bitrate);
|
||||
Navigator.pop(sheetContext);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMusixmatchLanguagePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String currentLanguage,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final controller = TextEditingController(text: currentLanguage);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 24,
|
||||
right: 24,
|
||||
top: 24,
|
||||
bottom: 24 + MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Musixmatch Language',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Language code',
|
||||
hintText: 'auto / en / es / ja',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setMusixmatchLanguage('');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Auto'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final normalized = _normalizeMusixmatchLanguage(
|
||||
controller.text,
|
||||
);
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setMusixmatchLanguage(normalized);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getTidalHighFormatLabel(String format) {
|
||||
switch (format) {
|
||||
case 'mp3_320':
|
||||
|
||||
@@ -218,6 +218,11 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
title: context.l10n.extensionDownloadProvider,
|
||||
enabled: extension.hasDownloadProvider,
|
||||
),
|
||||
_CapabilityItem(
|
||||
icon: Icons.lyrics,
|
||||
title: context.l10n.extensionLyricsProvider,
|
||||
enabled: extension.hasLyricsProvider,
|
||||
),
|
||||
_CapabilityItem(
|
||||
icon: Icons.manage_search,
|
||||
title: context.l10n.extensionsSearchProvider,
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class LyricsProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const LyricsProviderPriorityPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LyricsProviderPriorityPage> createState() =>
|
||||
_LyricsProviderPriorityPageState();
|
||||
}
|
||||
|
||||
class _LyricsProviderPriorityPageState
|
||||
extends ConsumerState<LyricsProviderPriorityPage> {
|
||||
static const _allProviderIds = [
|
||||
'lrclib',
|
||||
'netease',
|
||||
'musixmatch',
|
||||
'apple_music',
|
||||
'qqmusic',
|
||||
];
|
||||
|
||||
late List<String> _enabledProviders;
|
||||
late List<String> _initialProviders;
|
||||
bool _hasChanges = false;
|
||||
|
||||
List<String> get _disabledProviders => _allProviderIds
|
||||
.where((id) => !_enabledProviders.contains(id))
|
||||
.toList();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final settings = ref.read(settingsProvider);
|
||||
_enabledProviders = List.from(settings.lyricsProviders);
|
||||
_initialProviders = List.from(settings.lyricsProviders);
|
||||
}
|
||||
|
||||
void _markChanged() {
|
||||
final changed = _enabledProviders.length != _initialProviders.length ||
|
||||
!_enabledProviders
|
||||
.asMap()
|
||||
.entries
|
||||
.every((e) =>
|
||||
e.key < _initialProviders.length &&
|
||||
_initialProviders[e.key] == e.value);
|
||||
setState(() => _hasChanges = changed);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final disabled = _disabledProviders;
|
||||
|
||||
return PopScope(
|
||||
canPop: !_hasChanges,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// ── Collapsing App Bar ──
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () async {
|
||||
if (_hasChanges) {
|
||||
final shouldPop = await _confirmDiscard(context);
|
||||
if (shouldPop && context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (_hasChanges)
|
||||
TextButton(
|
||||
onPressed: _saveChanges,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding:
|
||||
EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'Lyrics Providers',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── Description ──
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: Text(
|
||||
'Enable, disable and reorder lyrics sources. '
|
||||
'Providers are tried top-to-bottom until lyrics are found.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Enabled section header ──
|
||||
if (_enabledProviders.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: 'Enabled (${_enabledProviders.length})',
|
||||
),
|
||||
),
|
||||
|
||||
// ── Reorderable enabled list ──
|
||||
if (_enabledProviders.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverReorderableList(
|
||||
itemCount: _enabledProviders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final id = _enabledProviders[index];
|
||||
final info = _getLyricsProviderInfo(id);
|
||||
return _EnabledProviderItem(
|
||||
key: ValueKey(id),
|
||||
providerId: id,
|
||||
info: info,
|
||||
index: index,
|
||||
isFirst: index == 0,
|
||||
onToggle: () => _disableProvider(id),
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final item = _enabledProviders.removeAt(oldIndex);
|
||||
_enabledProviders.insert(newIndex, item);
|
||||
});
|
||||
_markChanged();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── Disabled section header ──
|
||||
if (disabled.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: 'Disabled (${disabled.length})',
|
||||
),
|
||||
),
|
||||
|
||||
// ── Disabled list ──
|
||||
if (disabled.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final id = disabled[index];
|
||||
final info = _getLyricsProviderInfo(id);
|
||||
return _DisabledProviderItem(
|
||||
key: ValueKey(id),
|
||||
providerId: id,
|
||||
info: info,
|
||||
onToggle: () => _enableProvider(id),
|
||||
);
|
||||
},
|
||||
childCount: disabled.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Info banner ──
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline,
|
||||
size: 20, color: colorScheme.tertiary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Extension lyrics providers always run before '
|
||||
'built-in providers. At least one provider must '
|
||||
'remain enabled.',
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── State mutations ──
|
||||
|
||||
void _enableProvider(String id) {
|
||||
setState(() => _enabledProviders.add(id));
|
||||
_markChanged();
|
||||
}
|
||||
|
||||
void _disableProvider(String id) {
|
||||
if (_enabledProviders.length <= 1) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('At least one provider must remain enabled'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _enabledProviders.remove(id));
|
||||
_markChanged();
|
||||
}
|
||||
|
||||
// ── Save / Discard ──
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsProviders(List<String>.from(_enabledProviders));
|
||||
setState(() {
|
||||
_initialProviders = List.from(_enabledProviders);
|
||||
_hasChanges = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Lyrics provider priority saved')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _confirmDiscard(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Discard changes?'),
|
||||
content:
|
||||
const Text('You have unsaved changes that will be lost.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Discard'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
// ── Provider metadata ──
|
||||
|
||||
static _LyricsProviderInfo _getLyricsProviderInfo(String id) {
|
||||
switch (id) {
|
||||
case 'lrclib':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'LRCLIB',
|
||||
description: 'Open-source synced lyrics database',
|
||||
icon: Icons.subtitles_outlined,
|
||||
);
|
||||
case 'netease':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'Netease',
|
||||
description: 'NetEase Cloud Music (good for Asian songs)',
|
||||
icon: Icons.cloud_outlined,
|
||||
);
|
||||
case 'musixmatch':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'Musixmatch',
|
||||
description: 'Largest lyrics database (multi-language)',
|
||||
icon: Icons.translate,
|
||||
);
|
||||
case 'apple_music':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'Apple Music',
|
||||
description: 'Word-by-word synced lyrics (via proxy)',
|
||||
icon: Icons.music_note,
|
||||
);
|
||||
case 'qqmusic':
|
||||
return _LyricsProviderInfo(
|
||||
name: 'QQ Music',
|
||||
description: 'QQ Music (good for Chinese songs, via proxy)',
|
||||
icon: Icons.queue_music,
|
||||
);
|
||||
default:
|
||||
return _LyricsProviderInfo(
|
||||
name: id,
|
||||
description: 'Extension provider',
|
||||
icon: Icons.extension,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Enabled provider card (reorderable)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _EnabledProviderItem extends StatelessWidget {
|
||||
final String providerId;
|
||||
final _LyricsProviderInfo info;
|
||||
final int index;
|
||||
final bool isFirst;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
const _EnabledProviderItem({
|
||||
super.key,
|
||||
required this.providerId,
|
||||
required this.info,
|
||||
required this.index,
|
||||
required this.isFirst,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final backgroundColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Numbered badge
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isFirst
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFirst
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Icon
|
||||
Icon(info.icon, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
// Name + description
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.description,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Enable/disable switch
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: FittedBox(
|
||||
child: Switch(
|
||||
value: true,
|
||||
onChanged: (_) => onToggle(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Drag handle
|
||||
Icon(
|
||||
Icons.drag_handle,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Disabled provider card
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _DisabledProviderItem extends StatelessWidget {
|
||||
final String providerId;
|
||||
final _LyricsProviderInfo info;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
const _DisabledProviderItem({
|
||||
super.key,
|
||||
required this.providerId,
|
||||
required this.info,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final backgroundColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.03),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerLow;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Opacity(
|
||||
opacity: 0.6,
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onToggle,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Empty space aligned with numbered badge
|
||||
const SizedBox(width: 28),
|
||||
const SizedBox(width: 16),
|
||||
// Icon (muted)
|
||||
Icon(info.icon, color: colorScheme.outline),
|
||||
const SizedBox(width: 12),
|
||||
// Name + description
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
info.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.description,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Switch
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: FittedBox(
|
||||
child: Switch(
|
||||
value: false,
|
||||
onChanged: (_) => onToggle(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Provider info model
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _LyricsProviderInfo {
|
||||
final String name;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
|
||||
const _LyricsProviderInfo({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -56,6 +57,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
String? _rawLyrics; // Raw LRC with timestamps for embedding
|
||||
bool _lyricsLoading = false;
|
||||
String? _lyricsError;
|
||||
String? _lyricsSource;
|
||||
bool _showTitleInAppBar = false;
|
||||
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
|
||||
bool _isEmbedding = false; // Track embed operation in progress
|
||||
@@ -69,6 +71,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
r'^\[\d{2}:\d{2}\.\d{2,3}\]',
|
||||
);
|
||||
static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$');
|
||||
static final RegExp _lrcInlineTimestampPattern = RegExp(
|
||||
r'<\d{2}:\d{2}\.\d{2,3}>',
|
||||
);
|
||||
static final RegExp _lrcSpeakerPrefixPattern = RegExp(r'^(v1|v2):\s*');
|
||||
static final RegExp _lrcBackgroundLinePattern = RegExp(r'^\[bg:(.*)\]$');
|
||||
static final RegExp _invalidFileNameChars = RegExp(r'[<>:"/\\|?*\x00-\x1f]');
|
||||
static final RegExp _multiUnderscore = RegExp(r'_+');
|
||||
static final RegExp _leadingOrTrailingDots = RegExp(r'^\.+|\.+$');
|
||||
static const List<String> _months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
@@ -1339,6 +1349,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_lyricsSource != null && _lyricsSource!.trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Source: ${_lyricsSource!}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (_lyricsLoading)
|
||||
@@ -1460,6 +1480,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_lyricsLoading = true;
|
||||
_lyricsError = null;
|
||||
_isInstrumental = false;
|
||||
_lyricsSource = null;
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -1468,20 +1489,31 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
// First, check if lyrics are embedded in the file
|
||||
if (_fileExists) {
|
||||
final embeddedResult = await PlatformBridge.getLyricsLRC(
|
||||
'',
|
||||
trackName,
|
||||
artistName,
|
||||
filePath: cleanFilePath,
|
||||
durationMs: 0,
|
||||
).timeout(const Duration(seconds: 5), onTimeout: () => '');
|
||||
final embeddedResult =
|
||||
await PlatformBridge.getLyricsLRCWithSource(
|
||||
'',
|
||||
trackName,
|
||||
artistName,
|
||||
filePath: cleanFilePath,
|
||||
durationMs: 0,
|
||||
).timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => <String, dynamic>{'lyrics': '', 'source': ''},
|
||||
);
|
||||
|
||||
if (embeddedResult.isNotEmpty) {
|
||||
final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? '';
|
||||
final embeddedSource = embeddedResult['source']?.toString() ?? '';
|
||||
|
||||
if (embeddedLyrics.isNotEmpty) {
|
||||
// Lyrics found in file
|
||||
if (mounted) {
|
||||
final cleanLyrics = _cleanLrcForDisplay(embeddedResult);
|
||||
final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics);
|
||||
setState(() {
|
||||
_lyrics = cleanLyrics;
|
||||
_rawLyrics = embeddedLyrics;
|
||||
_lyricsSource = embeddedSource.isNotEmpty
|
||||
? embeddedSource
|
||||
: 'Embedded';
|
||||
_lyricsEmbedded = true;
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
@@ -1491,43 +1523,55 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
// No embedded lyrics, fetch from online
|
||||
final result = await PlatformBridge.getLyricsLRC(
|
||||
final result = await PlatformBridge.getLyricsLRCWithSource(
|
||||
_spotifyId ?? '',
|
||||
trackName,
|
||||
artistName,
|
||||
filePath: null, // Don't check file again
|
||||
durationMs: durationMs,
|
||||
).timeout(const Duration(seconds: 20), onTimeout: () => '');
|
||||
).timeout(const Duration(seconds: 20));
|
||||
|
||||
final lrcText = result['lyrics']?.toString() ?? '';
|
||||
final source = result['source']?.toString() ?? '';
|
||||
final instrumental =
|
||||
(result['instrumental'] as bool? ?? false) ||
|
||||
lrcText == '[instrumental:true]';
|
||||
|
||||
if (mounted) {
|
||||
// Check for instrumental marker
|
||||
if (result == '[instrumental:true]') {
|
||||
if (instrumental) {
|
||||
setState(() {
|
||||
_isInstrumental = true;
|
||||
_lyricsSource = source.isNotEmpty ? source : null;
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
} else if (result.isEmpty) {
|
||||
} else if (lrcText.isEmpty) {
|
||||
setState(() {
|
||||
_lyricsError = context.l10n.trackLyricsNotAvailable;
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
} else {
|
||||
final cleanLyrics = _cleanLrcForDisplay(result);
|
||||
final cleanLyrics = _cleanLrcForDisplay(lrcText);
|
||||
setState(() {
|
||||
_lyrics = cleanLyrics;
|
||||
_rawLyrics = result; // Keep raw LRC with timestamps for embedding
|
||||
_rawLyrics = lrcText; // Keep raw LRC with timestamps for embedding
|
||||
_lyricsSource = source.isNotEmpty ? source : null;
|
||||
_lyricsEmbedded = false; // Lyrics from online, not embedded
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
} on TimeoutException {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_lyricsError = context.l10n.trackLyricsTimeout;
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
final errorMsg = e.toString().contains('TimeoutException')
|
||||
? context.l10n.trackLyricsTimeout
|
||||
: context.l10n.trackLyricsLoadFailed;
|
||||
setState(() {
|
||||
_lyricsError = errorMsg;
|
||||
_lyricsError = context.l10n.trackLyricsLoadFailed;
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -1681,9 +1725,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
String _sanitizeFileNameSegment(String value) {
|
||||
var sanitized = value.replaceAll(_invalidFileNameChars, '_').trim();
|
||||
sanitized = sanitized.replaceAll(_leadingOrTrailingDots, '');
|
||||
sanitized = sanitized.replaceAll(_multiUnderscore, '_');
|
||||
if (sanitized.isEmpty) {
|
||||
return 'untitled';
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
String _buildSaveBaseName() {
|
||||
final artist = artistName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
||||
final track = trackName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
||||
final artist = _sanitizeFileNameSegment(artistName);
|
||||
final track = _sanitizeFileNameSegment(trackName);
|
||||
return '$artist - $track';
|
||||
}
|
||||
|
||||
@@ -2213,17 +2267,28 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final cleanLines = <String>[];
|
||||
|
||||
for (final line in lines) {
|
||||
final trimmedLine = line.trim();
|
||||
var cleaned = line.trim();
|
||||
|
||||
// Skip metadata tags
|
||||
if (_lrcMetadataPattern.hasMatch(trimmedLine)) {
|
||||
if (_lrcMetadataPattern.hasMatch(cleaned) &&
|
||||
!_lrcBackgroundLinePattern.hasMatch(cleaned)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove timestamp and clean up
|
||||
final cleanLine = trimmedLine.replaceAll(_lrcTimestampPattern, '').trim();
|
||||
if (cleanLine.isNotEmpty) {
|
||||
cleanLines.add(cleanLine);
|
||||
// Convert [bg:...] wrapper to a plain secondary vocal line.
|
||||
final bgMatch = _lrcBackgroundLinePattern.firstMatch(cleaned);
|
||||
if (bgMatch != null) {
|
||||
cleaned = bgMatch.group(1)?.trim() ?? '';
|
||||
}
|
||||
|
||||
// Remove line timestamp, inline word-by-word timestamps, and speaker prefix.
|
||||
cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim();
|
||||
cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, '');
|
||||
cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, '');
|
||||
cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
|
||||
if (cleaned.isNotEmpty) {
|
||||
cleanLines.add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -41,17 +42,7 @@ class CoverCacheManager {
|
||||
|
||||
debugPrint('CoverCacheManager: Initializing at $_cachePath');
|
||||
|
||||
_instance = CacheManager(
|
||||
Config(
|
||||
_cacheKey,
|
||||
stalePeriod: _maxCacheAge,
|
||||
maxNrOfCacheObjects: _maxCacheObjects,
|
||||
// Use path only (not databaseName) to store database in persistent directory
|
||||
repo: JsonCacheInfoRepository(path: _cachePath),
|
||||
fileSystem: IOFileSystem(_cachePath!),
|
||||
fileService: HttpFileService(),
|
||||
),
|
||||
);
|
||||
_instance = _createManager(_cachePath!);
|
||||
|
||||
_initialized = true;
|
||||
debugPrint('CoverCacheManager: Initialized successfully');
|
||||
@@ -62,12 +53,47 @@ class CoverCacheManager {
|
||||
}
|
||||
|
||||
static Future<void> clearCache() async {
|
||||
if (!_initialized || _instance == null) return;
|
||||
await _instance!.emptyCache();
|
||||
if (!_initialized || _instance == null || _cachePath == null) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
final instance = _instance;
|
||||
final cachePath = _cachePath;
|
||||
|
||||
if (instance == null || cachePath == null) return;
|
||||
|
||||
// Ask cache manager to clear indexed entries first.
|
||||
try {
|
||||
await instance.emptyCache();
|
||||
} catch (e) {
|
||||
debugPrint('CoverCacheManager: emptyCache failed, fallback to wipe: $e');
|
||||
}
|
||||
|
||||
// Then wipe the directory to remove orphaned files/metadata leftovers.
|
||||
await _wipeDirectory(cachePath);
|
||||
|
||||
// Clear in-memory image cache so cleared covers are not retained in RAM.
|
||||
final imageCache = PaintingBinding.instance.imageCache;
|
||||
imageCache.clear();
|
||||
imageCache.clearLiveImages();
|
||||
|
||||
// Reset manager memory/index state after on-disk wipe.
|
||||
instance.store.emptyMemoryCache();
|
||||
_instance = _createManager(cachePath);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static Future<CacheStats> getStats() async {
|
||||
if (!_initialized || _cachePath == null) {
|
||||
if (_cachePath == null) {
|
||||
try {
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
_cachePath = p.join(appDir.path, 'cover_cache');
|
||||
} catch (_) {
|
||||
return const CacheStats(fileCount: 0, totalSizeBytes: 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (_cachePath == null) {
|
||||
return const CacheStats(fileCount: 0, totalSizeBytes: 0);
|
||||
}
|
||||
|
||||
@@ -93,6 +119,45 @@ class CoverCacheManager {
|
||||
|
||||
return CacheStats(fileCount: fileCount, totalSizeBytes: totalSize);
|
||||
}
|
||||
|
||||
static CacheManager _createManager(String cachePath) {
|
||||
return CacheManager(
|
||||
Config(
|
||||
_cacheKey,
|
||||
stalePeriod: _maxCacheAge,
|
||||
maxNrOfCacheObjects: _maxCacheObjects,
|
||||
// Use path only (not databaseName) to store database in persistent directory
|
||||
repo: JsonCacheInfoRepository(path: cachePath),
|
||||
fileSystem: IOFileSystem(cachePath),
|
||||
fileService: HttpFileService(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _wipeDirectory(String path) async {
|
||||
final directory = Directory(path);
|
||||
if (!await directory.exists()) {
|
||||
await directory.create(recursive: true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final entities = <FileSystemEntity>[];
|
||||
await for (final entity in directory.list(followLinks: false)) {
|
||||
entities.add(entity);
|
||||
}
|
||||
|
||||
for (final entity in entities) {
|
||||
try {
|
||||
await entity.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
await directory.create(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
class CacheStats {
|
||||
|
||||
@@ -276,6 +276,23 @@ class PlatformBridge {
|
||||
return result as String;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getLyricsLRCWithSource(
|
||||
String spotifyId,
|
||||
String trackName,
|
||||
String artistName, {
|
||||
String? filePath,
|
||||
int durationMs = 0,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('getLyricsLRCWithSource', {
|
||||
'spotify_id': spotifyId,
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
'file_path': filePath ?? '',
|
||||
'duration_ms': durationMs,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> embedLyricsToFile(
|
||||
String filePath,
|
||||
String lyrics,
|
||||
@@ -332,6 +349,47 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
// ==================== LYRICS PROVIDER SETTINGS ====================
|
||||
|
||||
/// Sets the lyrics provider order. Providers not in the list are disabled.
|
||||
static Future<void> setLyricsProviders(List<String> providers) async {
|
||||
final providersJSON = jsonEncode(providers);
|
||||
await _channel.invokeMethod('setLyricsProviders', {
|
||||
'providers_json': providersJSON,
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the current lyrics provider order.
|
||||
static Future<List<String>> getLyricsProviders() async {
|
||||
final result = await _channel.invokeMethod('getLyricsProviders');
|
||||
final List<dynamic> decoded = jsonDecode(result as String) as List<dynamic>;
|
||||
return decoded.cast<String>();
|
||||
}
|
||||
|
||||
/// Returns metadata about all available lyrics providers.
|
||||
static Future<List<Map<String, dynamic>>>
|
||||
getAvailableLyricsProviders() async {
|
||||
final result = await _channel.invokeMethod('getAvailableLyricsProviders');
|
||||
final List<dynamic> decoded = jsonDecode(result as String) as List<dynamic>;
|
||||
return decoded.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// Sets advanced lyrics fetch options used by provider-specific integrations.
|
||||
static Future<void> setLyricsFetchOptions(
|
||||
Map<String, dynamic> options,
|
||||
) async {
|
||||
final optionsJSON = jsonEncode(options);
|
||||
await _channel.invokeMethod('setLyricsFetchOptions', {
|
||||
'options_json': optionsJSON,
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns current advanced lyrics fetch options.
|
||||
static Future<Map<String, dynamic>> getLyricsFetchOptions() async {
|
||||
final result = await _channel.invokeMethod('getLyricsFetchOptions');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> reEnrichFile(
|
||||
Map<String, dynamic> request,
|
||||
) async {
|
||||
|
||||