Compare commits

...

17 Commits

Author SHA1 Message Date
zarzet 813ed79073 refactor: conditionally show lyrics settings only when embed lyrics is enabled 2026-02-17 20:57:50 +07:00
Zarz Eleutherius 537bab69ab Merge pull request #151 from zarzet/renovate/go-dependencies
fix(deps): update go dependencies
2026-02-17 18:28:44 +07:00
Zarz Eleutherius b0871ad94b Merge pull request #158 from zarzet/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2026-02-17 18:28:31 +07:00
Zarz Eleutherius 0bd7574ab2 Merge pull request #159 from zarzet/renovate/actions-upload-pages-artifact-4.x
chore(deps): update actions/upload-pages-artifact action to v4
2026-02-17 18:28:16 +07:00
renovate[bot] c3f8b48bf7 fix(deps): update go dependencies 2026-02-17 11:28:10 +00:00
zarzet 90f731ac1e fix: refine launcher icons and settings page presentation
Polish generated app icon handling and improve the settings page supporter section layout for better scalability and readability.
2026-02-17 18:26:20 +07:00
zarzet 8e6cbcbc2a feat: YouTube customizable bitrate, improved title matching, SpotubeDL engine fallback
- Add configurable YouTube Opus (96-256kbps) and MP3 (96-320kbps) bitrates
- Improve title matching with loose normalization for symbol-heavy tracks
- Add SpotubeDL engine v2 fallback for MP3 requests
- Improve filename sanitization in track metadata screen
- Bump version to 3.6.9+82
2026-02-17 17:22:24 +07:00
zarzet 3ac9ff1dd7 docs: update Paxsenix integration to include all supported providers 2026-02-14 17:26:53 +07:00
zarzet 3e90b29d2b fix: improve lyrics error detection and add new donor
- Detect error JSON payloads from Apple Music and QQMusic proxies
- Add lyricsHasUsableText validation for usable lyrics content
- Skip empty word lines in synced lyrics parsing
- Add ClearAll method to lyrics cache
- Handle TimeoutException properly in track metadata screen
- Add laflame to recent donors list
2026-02-14 17:25:17 +07:00
zarzet b74186464b chore: update CHANGELOG for v3.6.8 2026-02-14 02:18:43 +07:00
zarzet f4934dcb28 feat: add lyrics source tracking, Paxsenix partner, and dedicated lyrics provider settings page
- Add getLyricsLRCWithSource to return lyrics with source metadata
- Display lyrics source in track metadata screen
- Improve LRC parsing to preserve background vocal tags
- Add dedicated LyricsProviderPriorityPage for provider configuration
- Add Paxsenix as lyrics proxy partner for Apple Music/QQ Music
- Handle inline timestamps and speaker prefixes in LRC display
2026-02-14 02:15:36 +07:00
zarzet 30973a8e78 feat: lyrics provider extensions, configurable lyrics cascade, and iOS method channel parity
Add lyrics_provider as a new extension type alongside metadata_provider and
download_provider. Extensions implementing fetchLyrics() are called before
built-in providers, giving user-installed extensions highest priority.

Built-in lyrics cascade is now configurable from Download Settings:
  - Reorderable provider list (LRCLIB, Musixmatch, Netease, Apple Music, QQ Music)
  - Per-provider options: Netease translation/romanization, Apple/QQ multi-person
    word-by-word speaker tags, Musixmatch language code
  - Provider order and options synced to Go backend on app start and on change

Go backend changes:
  - New lyrics_provider manifest type with validation (extension_manifest.go)
  - ExtensionProviderWrapper.FetchLyrics() with Goja JS bridge (extension_providers.go)
  - Configurable SetLyricsProviderOrder/GetLyricsProviderOrder cascade (lyrics.go)
  - LyricsFetchOptions struct for per-provider settings (lyrics.go)
  - Extracted tryLRCLIB() helper, randomized LRCLIB User-Agent (lyrics.go)
  - Refactored msToLRCTimestamp to separate msToLRCTimestampInline (lyrics.go)
  - New provider source files: lyrics_apple.go, lyrics_musixmatch.go,
    lyrics_netease.go, lyrics_qqmusic.go
  - JSON export functions for lyrics settings (exports.go)
  - hasLyricsProvider field in extension manager JSON output

Platform channels:
  - Android (MainActivity.kt): setLyricsProviders, getLyricsProviders,
    getAvailableLyricsProviders, setLyricsFetchOptions, getLyricsFetchOptions
  - iOS (AppDelegate.swift): same 5 method channel handlers for iOS parity

Flutter side:
  - Extension model: hasLyricsProvider field + Lyrics Provider capability badge
  - Settings model: lyricsProviders, lyricsIncludeTranslationNetease,
    lyricsIncludeRomanizationNetease, lyricsMultiPersonWordByWord,
    musixmatchLanguage fields with generated serialization
  - Settings provider: setters + _syncLyricsSettingsToBackend()
  - Download settings UI: provider picker, toggle switches, language picker
  - Platform bridge: lyrics provider/options methods

Docs: lyrics provider extension documentation in site/docs.html
CHANGELOG: updated with lyrics provider and search feature entries
2026-02-14 01:42:18 +07:00
zarzet 9b89625660 feat(site): add global search modal, search trigger styling, and remove emoji from docs
- Add search modal with full keyboard navigation (Ctrl+K, arrows, Enter, Esc) to all pages
- Search opens in-page on every page with static docs index; results navigate to docs#section
- Search trigger in desktop nav styled as bordered pill chip with hover states
- Add Search Docs link in mobile hamburger menus
- Fix nav-links vertical alignment with align-items: center
- Remove all colored emoji from docs.html (checkmarks, crosses, music note)
2026-02-14 00:54:46 +07:00
zarzet c70ba5962e feat(site): add copyright and MIT license to all page footers 2026-02-13 22:47:50 +07:00
renovate[bot] 8c722b0a18 chore(deps): update actions/upload-pages-artifact action to v4 2026-02-13 14:42:53 +00:00
renovate[bot] 3ece6770e1 chore(deps): update actions/checkout action to v6 2026-02-13 14:42:49 +00:00
zarzet 1407018d98 feat: advanced filename templates, low-RAM device profiling, responsive artist UI, and project site
- Add advanced filename template placeholders: {track_raw}, {disc_raw}, {date},
  formatted numbers {track:N}/{disc:N}, and date formatting {date:%Y-%m-%d}
  with strftime-to-Go layout conversion and robust date parser
- Pass date/release_date metadata to filename builder in all providers
  (Amazon, Qobuz, Tidal, YouTube, extensions) and Flutter download queue
- Detect ARM32-only / low-RAM Android devices at startup and reduce image
  cache size and disable overscroll effects for smoother experience
- Make artist screen selection bar responsive: compact stacked layout on
  narrow screens or large text scale; add quality picker before track download
- Add advanced tags toggle in download settings filename format editor
- Fix ICU plural syntax in DE/ES/PT/RU translations (one {}=1{...} -> one {...})
- Add filenameShowAdvancedTags l10n strings (EN, ID) and regenerate dart files
- Fix featured-artist regex: remove '&' from split separators
- Add Go filename template tests (filename_test.go)
- Add GitHub Pages workflow and static project site
2026-02-13 21:39:08 +07:00
111 changed files with 19666 additions and 3393 deletions
+44
View File
@@ -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
+100
View File
@@ -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
+1 -1
View File
@@ -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) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 932 B

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 B

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

+1 -1
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 811 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 122 KiB

+1
View File
@@ -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
+115 -1
View File
@@ -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.
+2
View File
@@ -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,
+7 -2
View File
@@ -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 {
+143
View File
@@ -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
}
+234 -29
View File
@@ -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]
+85
View File
@@ -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)
}
}
+2 -2
View File
@@ -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
)
+4
View File
@@ -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=
+408 -49
View File
@@ -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 {
+381
View File
@@ -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")
}
+214
View File
@@ -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")
}
+209
View File
@@ -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
}
+211
View File
@@ -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")
}
+21
View File
@@ -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
+21
View File
@@ -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,
})
+43
View File
@@ -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
}
+34
View File
@@ -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")
}
}
+174 -45
View File
@@ -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
+41
View File
@@ -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)
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+41
View File
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 B

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 576 B

After

Width:  |  Height:  |  Size: 905 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 744 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 B

After

Width:  |  Height:  |  Size: 624 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 789 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 576 B

After

Width:  |  Height:  |  Size: 905 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 752 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 932 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

+19 -15
View File
@@ -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,
+2 -2
View File
@@ -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';
+48
View File
@@ -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:
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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';
File diff suppressed because it is too large Load Diff
+116 -79
View File
@@ -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';
+31
View File
@@ -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';
+137 -112
View File
@@ -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';
}
+31
View File
@@ -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 => 'ダウンロード前に確認する';
+43 -13
View File
@@ -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';
+31
View File
@@ -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';
File diff suppressed because it is too large Load Diff
+258 -175
View File
@@ -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
+31
View File
@@ -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';
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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": {
+39
View File
@@ -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"},
+10 -10
View File
@@ -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": {
+81 -22
View File
@@ -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"
}
}
}
+10 -10
View File
@@ -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": {
+7 -7
View File
@@ -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": {
+55 -5
View File
@@ -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
+51
View File
@@ -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,
);
}
+67 -45
View File
@@ -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,
};
+26 -4
View File
@@ -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,
+5
View File
@@ -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,
+108
View File
@@ -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();
+170 -54
View File
@@ -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,
),
+8
View File
@@ -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,
),
],
+67 -60
View File
@@ -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),
),
),
],
+441 -128
View File
@@ -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,
});
}
+92 -27
View File
@@ -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);
}
}
+79 -14
View File
@@ -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 {
+58
View File
@@ -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 {

Some files were not shown because too many files have changed in this diff Show More