Compare commits

..

129 Commits

Author SHA1 Message Date
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
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
zarzet 0dc89cf569 fix(deps): allow material_color_utilities sdk-pinned range 2026-02-12 02:33:54 +07:00
zarzet 3c1e9d03a0 fix(ios): recover notification permission and path handling 2026-02-12 02:23:54 +07:00
zarzet 28a082f47a fix(l10n): fix arb locale mismatch - rename es-ES/pt-PT to underscore format 2026-02-12 01:28:56 +07:00
zarzet 38994d5900 chore: bump pubspec.yaml version to 3.6.6+80 2026-02-12 01:16:49 +07:00
zarzet 472896328a Merge remote-tracking branch 'origin/dev' into dev 2026-02-12 01:15:50 +07:00
zarzet 92f408035a feat: enable library scan notifications on iOS (remove Android-only guard) 2026-02-12 01:14:10 +07:00
zarzet 979186243c docs: update changelog v3.6.6 with new features and translation update 2026-02-12 01:11:12 +07:00
zarzet ee66247bea Merge remote-tracking branch 'origin/l10n_dev' into dev
# Conflicts:
#	lib/l10n/arb/app_es-ES.arb
#	lib/l10n/arb/app_id.arb
#	lib/l10n/arb/app_pt-PT.arb
2026-02-12 01:10:04 +07:00
zarzet 66a9daf733 chore: minor formatting and whitespace cleanup 2026-02-12 01:06:27 +07:00
zarzet 69a9e0cb40 feat: add library scan notifications - progress, complete, failed, cancelled notifications for local library scan - new notification channel for library scan - Android only 2026-02-12 01:06:17 +07:00
zarzet cd6beaa7d4 feat: add filterContributingArtistsInAlbumArtist setting - new option to strip contributing artists from Album Artist metadata - applies to folder organization and metadata embedding - collapsible Artist Name Filters section in download settings UI 2026-02-12 01:06:08 +07:00
zarzet 5f4ff17630 refactor(ios): remove legacy download handlers, use downloadByStrategy only 2026-02-12 01:02:48 +07:00
zarzet 3c3bbe516e v3.6.6: fix iOS downloads, metadata fallback, lossy quality display, audio duration accuracy 2026-02-12 00:32:40 +07:00
zarzet a1d1ab1f0f fix: preserve extended metadata during fallback, accurate lossy quality display, SAF improvements
- Add Genre/Label/Copyright fields to DownloadResult struct
- buildDownloadSuccessResponse now prefers service result metadata over request
- enrichRequestExtendedMetadata fetches Deezer metadata by ISRC before download
- Flutter sends copyright in download request payload
- History merge preserves existing genre/label/copyright on re-download
- Accurate MP3 duration via Xing/VBRI VBR headers, MPEG2/2.5 bitrate tables
- Accurate Opus/Vorbis duration via last Ogg page granule position
- Bitrate field added to LibraryScanResult, LocalLibraryItem, DB v4 migration
- Lossy formats display format+bitrate instead of fake 16-bit quality
- Local library file date uses fileModTime instead of scannedAt
- SAF URI recovery for transient FD paths after download
- Improved SAF repair and download history path matching in library scan
- Extract quality probe logic into reusable enrichResultQualityFromFile
2026-02-12 00:19:02 +07:00
Zarz Eleutherius ab9456fff8 New translations app_en.arb (Turkish) 2026-02-11 16:12:15 +07:00
Zarz Eleutherius 2f673469aa New translations app_en.arb (Hindi) 2026-02-11 16:12:14 +07:00
Zarz Eleutherius 05fde22075 New translations app_en.arb (Indonesian) 2026-02-11 16:12:13 +07:00
Zarz Eleutherius deab7b7dd6 New translations app_en.arb (Chinese Traditional) 2026-02-11 16:12:11 +07:00
Zarz Eleutherius ae5da3b6e0 New translations app_en.arb (Chinese Simplified) 2026-02-11 16:12:10 +07:00
Zarz Eleutherius 4d0c8f49aa New translations app_en.arb (Russian) 2026-02-11 16:12:08 +07:00
Zarz Eleutherius 3068f4e367 New translations app_en.arb (Portuguese) 2026-02-11 16:12:07 +07:00
Zarz Eleutherius 3844704490 New translations app_en.arb (Dutch) 2026-02-11 16:12:06 +07:00
Zarz Eleutherius 12144b8220 New translations app_en.arb (Korean) 2026-02-11 16:12:04 +07:00
Zarz Eleutherius b639080494 New translations app_en.arb (Japanese) 2026-02-11 16:12:03 +07:00
Zarz Eleutherius e67d7d68cb New translations app_en.arb (German) 2026-02-11 16:12:01 +07:00
Zarz Eleutherius b8f18c1cf5 New translations app_en.arb (Spanish) 2026-02-11 16:12:00 +07:00
Zarz Eleutherius 529958c4af New translations app_en.arb (French) 2026-02-11 16:11:59 +07:00
Zarz Eleutherius 40077a577c Update source file app_en.arb 2026-02-11 16:11:57 +07:00
Zarz Eleutherius e0fbd706ce Merge pull request #145 from zarzet/renovate/go-dependencies
fix(deps): update go dependencies
2026-02-11 16:10:59 +07:00
Zarz Eleutherius b76879f204 Merge pull request #148 from zarzet/renovate/go-1.x
chore(deps): update dependency go to 1.26
2026-02-11 16:10:47 +07:00
renovate[bot] eefbb63299 fix(deps): update go dependencies 2026-02-11 05:31:18 +00:00
renovate[bot] fdbb474763 chore(deps): update dependency go to 1.26 2026-02-11 05:30:57 +00:00
zarzet 6a7eef6956 chore: add Elias el Autentico to supporters list 2026-02-11 12:28:50 +07:00
Zarz Eleutherius 803e0dc5a3 New translations app_en.arb (Turkish) 2026-02-10 19:46:50 +07:00
Zarz Eleutherius 474c37ec8e New translations app_en.arb (Hindi) 2026-02-10 19:46:46 +07:00
Zarz Eleutherius eb7726263a New translations app_en.arb (Indonesian) 2026-02-10 19:46:45 +07:00
Zarz Eleutherius f87ccc51c5 New translations app_en.arb (Chinese Traditional) 2026-02-10 19:46:43 +07:00
Zarz Eleutherius b0b4e7803c New translations app_en.arb (Chinese Simplified) 2026-02-10 19:46:42 +07:00
Zarz Eleutherius 450f19c656 New translations app_en.arb (Russian) 2026-02-10 19:46:41 +07:00
Zarz Eleutherius 55b9c08f99 New translations app_en.arb (Portuguese) 2026-02-10 19:46:39 +07:00
Zarz Eleutherius a5f3aab775 New translations app_en.arb (Dutch) 2026-02-10 19:46:38 +07:00
Zarz Eleutherius 7442c9b106 New translations app_en.arb (Korean) 2026-02-10 19:46:37 +07:00
Zarz Eleutherius ae66cb478b New translations app_en.arb (Japanese) 2026-02-10 19:46:35 +07:00
Zarz Eleutherius 2516c3e618 New translations app_en.arb (German) 2026-02-10 19:46:34 +07:00
Zarz Eleutherius 02a5893279 New translations app_en.arb (Spanish) 2026-02-10 19:46:33 +07:00
Zarz Eleutherius bd0d653210 New translations app_en.arb (French) 2026-02-10 19:46:31 +07:00
Zarz Eleutherius 62626ddc08 Update source file app_en.arb 2026-02-10 19:46:29 +07:00
Zarz Eleutherius 9cd2b1d8c5 New translations app_en.arb (Russian) 2026-02-09 19:41:00 +07:00
Zarz Eleutherius 49f1fb43fa New translations app_en.arb (Spanish) 2026-02-09 19:40:58 +07:00
Zarz Eleutherius 65b521ff8b New translations app_en.arb (Turkish) 2026-02-08 19:17:59 +07:00
Zarz Eleutherius 6d578694e2 New translations app_en.arb (Hindi) 2026-02-08 19:17:58 +07:00
Zarz Eleutherius f7ec649b24 New translations app_en.arb (Indonesian) 2026-02-08 19:17:57 +07:00
Zarz Eleutherius 71a9e1baef New translations app_en.arb (Chinese Traditional) 2026-02-08 19:17:56 +07:00
Zarz Eleutherius 4a4adcb72e New translations app_en.arb (Chinese Simplified) 2026-02-08 19:17:55 +07:00
Zarz Eleutherius 3458f03158 New translations app_en.arb (Russian) 2026-02-08 19:17:54 +07:00
Zarz Eleutherius 4fe4a01840 New translations app_en.arb (Portuguese) 2026-02-08 19:17:53 +07:00
Zarz Eleutherius e5d6fddeda New translations app_en.arb (Dutch) 2026-02-08 19:17:52 +07:00
Zarz Eleutherius 370f5e3b8b New translations app_en.arb (Korean) 2026-02-08 19:17:51 +07:00
Zarz Eleutherius f5bb0820d5 New translations app_en.arb (Japanese) 2026-02-08 19:17:50 +07:00
Zarz Eleutherius feb6da3ecb New translations app_en.arb (German) 2026-02-08 19:17:48 +07:00
Zarz Eleutherius 39f28a12aa New translations app_en.arb (Spanish) 2026-02-08 19:17:47 +07:00
Zarz Eleutherius 416fc79637 New translations app_en.arb (French) 2026-02-08 19:17:46 +07:00
Zarz Eleutherius 1f43780bec Update source file app_en.arb 2026-02-08 19:17:44 +07:00
Zarz Eleutherius 481b4b03dc New translations app_en.arb (Turkish) 2026-02-07 19:20:11 +07:00
Zarz Eleutherius b7fd2f7902 New translations app_en.arb (Hindi) 2026-02-07 19:20:10 +07:00
Zarz Eleutherius f2e1e59d6a New translations app_en.arb (Indonesian) 2026-02-07 19:20:09 +07:00
Zarz Eleutherius 3af2ecf1f4 New translations app_en.arb (Chinese Traditional) 2026-02-07 19:20:08 +07:00
Zarz Eleutherius 1b2f2c891c New translations app_en.arb (Chinese Simplified) 2026-02-07 19:20:07 +07:00
Zarz Eleutherius 155f3259f2 New translations app_en.arb (Russian) 2026-02-07 19:20:06 +07:00
Zarz Eleutherius f52d8d68b8 New translations app_en.arb (Portuguese) 2026-02-07 19:20:05 +07:00
Zarz Eleutherius 216d6e152c New translations app_en.arb (Dutch) 2026-02-07 19:20:04 +07:00
Zarz Eleutherius b6f90e727c New translations app_en.arb (Korean) 2026-02-07 19:20:03 +07:00
Zarz Eleutherius 790bbc544f New translations app_en.arb (Japanese) 2026-02-07 19:20:02 +07:00
Zarz Eleutherius bd511f7dc6 New translations app_en.arb (German) 2026-02-07 19:20:01 +07:00
Zarz Eleutherius e91c8c28a8 New translations app_en.arb (Spanish) 2026-02-07 19:20:00 +07:00
Zarz Eleutherius 3c6d1afa97 New translations app_en.arb (French) 2026-02-07 19:19:59 +07:00
Zarz Eleutherius 3947e109b4 Update source file app_en.arb 2026-02-07 19:19:57 +07:00
Zarz Eleutherius bf87662f99 New translations app_en.arb (Russian) 2026-02-06 18:33:40 +07:00
Zarz Eleutherius 4273edd836 New translations app_en.arb (Russian) 2026-02-05 13:19:12 +07:00
Zarz Eleutherius 7ce41fc1c1 Update source file app_en.arb 2026-02-05 13:19:10 +07:00
Zarz Eleutherius fb7a576e00 New translations app_en.arb (Turkish) 2026-02-04 12:53:12 +07:00
Zarz Eleutherius 30a559b279 New translations app_en.arb (Hindi) 2026-02-04 12:53:11 +07:00
Zarz Eleutherius f77d5fdf14 New translations app_en.arb (Indonesian) 2026-02-04 12:53:10 +07:00
Zarz Eleutherius 0a0667889c New translations app_en.arb (Chinese Traditional) 2026-02-04 12:53:09 +07:00
Zarz Eleutherius 14d8cd54d7 New translations app_en.arb (Chinese Simplified) 2026-02-04 12:53:08 +07:00
Zarz Eleutherius 5fa3d405e6 New translations app_en.arb (Russian) 2026-02-04 12:53:07 +07:00
Zarz Eleutherius 34eb335fd0 New translations app_en.arb (Portuguese) 2026-02-04 12:53:06 +07:00
Zarz Eleutherius c910530927 New translations app_en.arb (Dutch) 2026-02-04 12:53:05 +07:00
Zarz Eleutherius 69e1a6cf6b New translations app_en.arb (Korean) 2026-02-04 12:53:04 +07:00
Zarz Eleutherius bd84613624 New translations app_en.arb (Japanese) 2026-02-04 12:53:02 +07:00
Zarz Eleutherius 0b4777fc6b New translations app_en.arb (German) 2026-02-04 12:53:01 +07:00
Zarz Eleutherius e22813caec New translations app_en.arb (Spanish) 2026-02-04 12:53:00 +07:00
Zarz Eleutherius 8f6e8432de New translations app_en.arb (French) 2026-02-04 12:52:59 +07:00
Zarz Eleutherius b3c98cecc3 New translations app_en.arb (Turkish) 2026-02-02 08:25:49 +07:00
Zarz Eleutherius 49a18a977b New translations app_en.arb (Hindi) 2026-02-02 08:25:48 +07:00
Zarz Eleutherius a5d0feeedf New translations app_en.arb (Indonesian) 2026-02-02 08:25:47 +07:00
Zarz Eleutherius a574e73b44 New translations app_en.arb (Chinese Traditional) 2026-02-02 08:25:46 +07:00
Zarz Eleutherius a66f6a739f New translations app_en.arb (Chinese Simplified) 2026-02-02 08:25:45 +07:00
Zarz Eleutherius cc7e1b54b6 New translations app_en.arb (Russian) 2026-02-02 08:25:44 +07:00
Zarz Eleutherius 28cb7fcd3d New translations app_en.arb (Portuguese) 2026-02-02 08:25:43 +07:00
Zarz Eleutherius aeb370beca New translations app_en.arb (Dutch) 2026-02-02 08:25:42 +07:00
Zarz Eleutherius 239707e2da New translations app_en.arb (Korean) 2026-02-02 08:25:41 +07:00
Zarz Eleutherius c1e2778735 New translations app_en.arb (Japanese) 2026-02-02 08:25:40 +07:00
Zarz Eleutherius fb608a554d New translations app_en.arb (German) 2026-02-02 08:25:39 +07:00
Zarz Eleutherius 7561065802 New translations app_en.arb (Spanish) 2026-02-02 08:25:38 +07:00
Zarz Eleutherius 56c8d89999 New translations app_en.arb (French) 2026-02-02 08:25:37 +07:00
Zarz Eleutherius 9192760f3c Update source file app_en.arb 2026-02-02 08:25:35 +07:00
Zarz Eleutherius 40ec24db69 New translations app_en.arb (Turkish) 2026-02-01 08:04:39 +07:00
Zarz Eleutherius ba8d0a3438 New translations app_en.arb (Hindi) 2026-02-01 08:04:38 +07:00
Zarz Eleutherius 82decf99a6 New translations app_en.arb (Indonesian) 2026-02-01 08:04:37 +07:00
Zarz Eleutherius 6ba9fc1fec New translations app_en.arb (Chinese Traditional) 2026-02-01 08:04:36 +07:00
Zarz Eleutherius 715d94c2ed New translations app_en.arb (Chinese Simplified) 2026-02-01 08:04:35 +07:00
Zarz Eleutherius e1a722f479 New translations app_en.arb (Russian) 2026-02-01 08:04:34 +07:00
Zarz Eleutherius edbe12c512 New translations app_en.arb (Portuguese) 2026-02-01 08:04:33 +07:00
Zarz Eleutherius 9fc6542792 New translations app_en.arb (Dutch) 2026-02-01 08:04:32 +07:00
Zarz Eleutherius 4c01ee26c2 New translations app_en.arb (Korean) 2026-02-01 08:04:31 +07:00
Zarz Eleutherius 813b9fcf61 New translations app_en.arb (Japanese) 2026-02-01 08:04:30 +07:00
Zarz Eleutherius fe070e0177 New translations app_en.arb (German) 2026-02-01 08:04:29 +07:00
Zarz Eleutherius 423bb87ed8 New translations app_en.arb (Spanish) 2026-02-01 08:04:28 +07:00
Zarz Eleutherius 1641f51b0c New translations app_en.arb (French) 2026-02-01 08:04:27 +07:00
Zarz Eleutherius 3f78a1f3d1 Update source file app_en.arb 2026-02-01 08:04:25 +07:00
91 changed files with 33690 additions and 4186 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@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
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
+2 -2
View File
@@ -71,7 +71,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
go-version: "1.26"
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
@@ -174,7 +174,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
go-version: "1.26"
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
+101
View File
@@ -1,5 +1,106 @@
# Changelog
## [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
- "Filter Contributing Artists in Album Artist" setting - strips featured/contributing artists from Album Artist metadata tag
- Library scan notifications (Android and iOS) - shows progress, completion, failure, and cancellation status
- Collapsible "Artist Name Filters" section in download settings UI
### Fixed
- Fixed downloads not working on iOS - missing `downloadByStrategy` and `downloadFromYouTube` method channel handlers in AppDelegate.swift
- Fixed extended metadata (genre, label, copyright) lost during service fallback (e.g. Tidal unavailable, falls back to Qobuz) - Go backend now enriches metadata from Deezer by ISRC before download and preserves it through the fallback chain
- Fixed local library showing incorrect "16-bit" quality label for lossy formats (MP3, Opus) - now displays actual bitrate (e.g. "MP3 320kbps")
- Fixed inaccurate Opus/Vorbis duration calculation (e.g. 4:11 showing as 8:44) - now reads granule position from last Ogg page for precise duration
- Fixed MP3 duration/bitrate inaccuracy for VBR files - added Xing/Info and VBRI header parsing with MPEG2/2.5 bitrate table support
- Fixed Track Metadata screen showing scan date instead of file date for local library items
- Fixed SAF content URI paths displayed as raw `content://` strings in Track Metadata - now shows human-readable paths
### Changed
- Removed legacy iOS download handlers (`downloadTrack`, `downloadWithFallback`, `downloadFromYouTube`) - iOS now uses `downloadByStrategy` only
- Updated translations from Crowdin (all 14 languages)
---
## [3.6.5] - 2026-02-10
### Highlights
+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: 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
+190 -44
View File
@@ -43,6 +43,7 @@ type OggQuality struct {
SampleRate int
BitDepth int
Duration int
Bitrate int // estimated bitrate in bps
}
// =============================================================================
@@ -664,50 +665,144 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
file.Seek(audioStart, io.SeekStart)
// Find first valid MP3 frame sync
frameHeader := make([]byte, 4)
for i := 0; i < 10000; i++ { // Search first 10KB
var frameStart int64 = -1
for i := 0; i < 10000; i++ {
if _, err := io.ReadFull(file, frameHeader); err != nil {
break
}
if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 {
version := (frameHeader[1] >> 3) & 0x03
layer := (frameHeader[1] >> 1) & 0x03
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
sampleRates := [][]int{
{11025, 12000, 8000},
{0, 0, 0},
{22050, 24000, 16000},
{44100, 48000, 32000},
}
if version < 4 && sampleRateIdx < 3 {
quality.SampleRate = sampleRates[version][sampleRateIdx]
}
if version == 3 && layer == 1 {
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
quality.BitDepth = 16
if quality.Bitrate > 0 {
audioSize := fileSize - audioStart - 128
if audioSize > 0 {
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
}
}
pos, _ := file.Seek(0, io.SeekCurrent)
frameStart = pos - 4
break
}
file.Seek(-3, io.SeekCurrent)
}
if frameStart < 0 {
return quality, nil
}
version := (frameHeader[1] >> 3) & 0x03
layer := (frameHeader[1] >> 1) & 0x03
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
channelMode := (frameHeader[3] >> 6) & 0x03
// Sample rate tables: [version][index]
// version: 0=MPEG2.5, 1=reserved, 2=MPEG2, 3=MPEG1
sampleRates := [][]int{
{11025, 12000, 8000},
{0, 0, 0},
{22050, 24000, 16000},
{44100, 48000, 32000},
}
if version < 4 && sampleRateIdx < 3 {
quality.SampleRate = sampleRates[version][sampleRateIdx]
}
// Bitrate tables for all MPEG versions and layers
// MPEG1 Layer III
if version == 3 && layer == 1 {
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
// MPEG2/2.5 Layer III
if (version == 0 || version == 2) && layer == 1 {
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
// Determine samples per frame for duration calculation
samplesPerFrame := 1152 // MPEG1 Layer III
if version == 0 || version == 2 {
samplesPerFrame = 576 // MPEG2/2.5 Layer III
}
// Try to read Xing/VBRI header from the first frame for VBR info
// Xing header offset depends on MPEG version and channel mode
var xingOffset int
if version == 3 { // MPEG1
if channelMode == 3 { // Mono
xingOffset = 17
} else {
xingOffset = 32
}
} else { // MPEG2/2.5
if channelMode == 3 {
xingOffset = 9
} else {
xingOffset = 17
}
}
// Read enough of the first frame to find Xing/VBRI header
xingBuf := make([]byte, 200)
file.Seek(frameStart+4, io.SeekStart)
n, _ := io.ReadFull(file, xingBuf)
xingBuf = xingBuf[:n]
vbrFrames := 0
vbrBytes := int64(0)
isVBR := false
// Check for Xing/Info header
if xingOffset+8 <= n {
tag := string(xingBuf[xingOffset : xingOffset+4])
if tag == "Xing" || tag == "Info" {
flags := binary.BigEndian.Uint32(xingBuf[xingOffset+4 : xingOffset+8])
off := xingOffset + 8
if flags&0x01 != 0 && off+4 <= n { // Frames flag
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[off : off+4]))
off += 4
}
if flags&0x02 != 0 && off+4 <= n { // Bytes flag
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[off : off+4]))
}
if vbrFrames > 0 {
isVBR = true
}
}
}
// Check for VBRI header (always at offset 32 from frame start + 4)
if !isVBR && 36+26 <= n {
if string(xingBuf[32:36]) == "VBRI" {
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[36+10 : 36+14]))
if vbrFrames > 0 {
isVBR = true
}
}
}
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
// Accurate duration from total frames
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
quality.Duration = int(totalSamples / int64(quality.SampleRate))
// Accurate average bitrate
if vbrBytes > 0 && quality.Duration > 0 {
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
} else if quality.Duration > 0 {
audioSize := fileSize - audioStart
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
}
} else if quality.Bitrate > 0 {
// CBR fallback: estimate duration from file size and frame bitrate
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
if audioSize > 0 {
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
}
}
return quality, nil
}
@@ -981,7 +1076,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
defer file.Close()
quality := &OggQuality{}
isOpus := false
packets, err := collectOggPackets(file, 5, 10)
if err != nil && len(packets) == 0 {
@@ -997,15 +1091,17 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
}
}
if streamType == oggStreamOpus {
isOpus = true
isOpus := streamType == oggStreamOpus
var preSkip int
if isOpus {
for _, pkt := range packets {
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
if quality.SampleRate == 0 {
quality.SampleRate = 48000
}
quality.BitDepth = 16
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
break
}
}
@@ -1013,26 +1109,76 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
for _, pkt := range packets {
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
quality.BitDepth = 16
break
}
}
}
// Read granule position from the last Ogg page for accurate duration
stat, err := file.Stat()
if err == nil {
// Very rough duration estimate based on file size
// Assume ~128kbps average for Opus, ~160kbps for Vorbis
avgBitrate := 128000
if !isOpus {
avgBitrate = 160000
if err != nil {
return quality, nil
}
fileSize := stat.Size()
granule := readLastOggGranulePosition(file, fileSize)
if granule > 0 {
if isOpus {
// Opus always uses 48kHz granule position internally
totalSamples := granule - int64(preSkip)
if totalSamples > 0 {
quality.Duration = int(totalSamples / 48000)
}
} else if quality.SampleRate > 0 {
quality.Duration = int(granule / int64(quality.SampleRate))
}
quality.Duration = int(stat.Size() * 8 / int64(avgBitrate))
}
// Calculate average bitrate from file size and actual duration
if quality.Duration > 0 {
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
}
return quality, nil
}
// readLastOggGranulePosition seeks to the end of the file and scans backwards
// to find the last Ogg page, then reads its granule position (bytes 6-13).
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
// Read the last chunk of the file to find the last OggS sync
searchSize := int64(65536)
if searchSize > fileSize {
searchSize = fileSize
}
buf := make([]byte, searchSize)
offset := fileSize - searchSize
if offset < 0 {
offset = 0
}
n, err := file.ReadAt(buf, offset)
if err != nil && n == 0 {
return 0
}
buf = buf[:n]
// Scan backwards for "OggS" magic
lastPageOffset := -1
for i := n - 4; i >= 0; i-- {
if buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S' {
lastPageOffset = i
break
}
}
if lastPageOffset < 0 || lastPageOffset+14 > n {
return 0
}
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64)
return int64(binary.LittleEndian.Uint64(buf[lastPageOffset+6 : lastPageOffset+14]))
}
// =============================================================================
// ID3v1 Genre List
// =============================================================================
+216 -29
View File
@@ -213,6 +213,9 @@ type DownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
Genre string
Label string
Copyright string
LyricsLRC string
DecryptionKey string
}
@@ -260,6 +263,21 @@ func buildDownloadSuccessResponse(
isrc = req.ISRC
}
genre := result.Genre
if genre == "" {
genre = req.Genre
}
label := result.Label
if label == "" {
label = req.Label
}
copyright := result.Copyright
if copyright == "" {
copyright = req.Copyright
}
return DownloadResponse{
Success: true,
Message: message,
@@ -277,14 +295,85 @@ func buildDownloadSuccessResponse(
DiscNumber: discNumber,
ISRC: isrc,
CoverURL: req.CoverURL,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Genre: genre,
Label: label,
Copyright: copyright,
LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
}
}
func shouldSkipQualityProbe(filePath string) bool {
path := strings.TrimSpace(filePath)
if path == "" {
return true
}
if strings.HasPrefix(path, "/proc/self/fd/") {
return true
}
// Content URI and other non-filesystem schemes cannot be read directly by os.Open.
if strings.Contains(path, "://") {
return true
}
return false
}
func enrichResultQualityFromFile(result *DownloadResult) {
if result == nil {
return
}
path := strings.TrimSpace(result.FilePath)
if shouldSkipQualityProbe(path) {
if strings.HasPrefix(path, "/proc/self/fd/") {
LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path)
}
return
}
quality, qErr := GetAudioQuality(path)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
return
}
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
}
func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req == nil {
return
}
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
deezerClient := GetDeezerClient()
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
if err != nil || extMeta == nil {
if err != nil {
GoLog("[DownloadWithFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
return
}
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Genre != "" || req.Label != "" {
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
}
}
func DownloadTrack(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
@@ -303,6 +392,8 @@ func DownloadTrack(requestJSON string) (string, error) {
AddAllowedDownloadDir(req.OutputDir)
}
enrichRequestExtendedMetadata(&req)
var result DownloadResult
var err error
@@ -390,11 +481,8 @@ func DownloadTrack(requestJSON string) (string, error) {
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
result.FilePath = actualPath
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
result,
@@ -407,14 +495,7 @@ func DownloadTrack(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Could not read quality from file: %v\n", qErr)
}
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
@@ -488,6 +569,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
AddAllowedDownloadDir(req.OutputDir)
}
enrichRequestExtendedMetadata(&req)
allServices := []string{"tidal", "qobuz", "amazon"}
preferredService := req.Service
if preferredService == "" {
@@ -585,11 +668,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
if err == nil {
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
result.FilePath = actualPath
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
result,
@@ -602,14 +682,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
GoLog("[Download] Could not read quality from file: %v\n", qErr)
}
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
@@ -935,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 {
@@ -1526,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)
}
}
+8 -8
View File
@@ -2,7 +2,7 @@ module github.com/zarz/spotiflac_android/go_backend
go 1.25.0
toolchain go1.25.7
toolchain go1.26.0
require (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
@@ -10,8 +10,8 @@ require (
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-20260204172633-1dceadbbeea3
golang.org/x/net v0.49.0
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af
golang.org/x/net v0.50.0
)
require (
@@ -20,10 +20,10 @@ require (
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/klauspost/compress v1.17.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
)
+14
View File
@@ -30,20 +30,34 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
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/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=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+9 -2
View File
@@ -28,6 +28,7 @@ type LibraryScanResult struct {
ReleaseDate string `json:"releaseDate,omitempty"`
BitDepth int `json:"bitDepth,omitempty"`
SampleRate int `json:"sampleRate,omitempty"`
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
Genre string `json:"genre,omitempty"`
Format string `json:"format,omitempty"`
}
@@ -289,8 +290,11 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
quality, err := GetMP3Quality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
if result.TrackName == "" {
@@ -326,8 +330,11 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
quality, err := GetOggQuality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
if result.TrackName == "" {
+397 -48
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 {
+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")
}
+1
View File
@@ -1180,6 +1180,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
+1
View File
@@ -1609,6 +1609,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
+1
View File
@@ -500,6 +500,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
+5
View File
@@ -46,6 +46,11 @@ post_install do |installer|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
definitions = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS']
unless definitions.include?('PERMISSION_NOTIFICATIONS=1')
definitions << 'PERMISSION_NOTIFICATIONS=1'
end
end
end
end
+85 -15
View File
@@ -83,18 +83,12 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "downloadTrack":
case "downloadByStrategy":
let requestJson = call.arguments as! String
let response = GobackendDownloadTrack(requestJson, &error)
let response = GobackendDownloadByStrategy(requestJson, &error)
if let error = error { throw error }
return response
case "downloadWithFallback":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithFallback(requestJson, &error)
if let error = error { throw error }
return response
case "getDownloadProgress":
let response = GobackendGetDownloadProgress()
return response
@@ -197,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]
@@ -209,6 +214,41 @@ import Gobackend // Import Go framework
case "cleanupConnections":
GobackendCleanupConnections()
return nil
case "downloadCoverToFile":
let args = call.arguments as! [String: Any]
let coverURL = args["cover_url"] as! String
let outputPath = args["output_path"] as! String
let maxQuality = args["max_quality"] as? Bool ?? true
GobackendDownloadCoverToFile(coverURL, outputPath, maxQuality, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "extractCoverToFile":
let args = call.arguments as! [String: Any]
let audioPath = args["audio_path"] as! String
let outputPath = args["output_path"] as! String
GobackendExtractCoverToFile(audioPath, outputPath, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "fetchAndSaveLyrics":
let args = call.arguments as! [String: Any]
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let spotifyId = args["spotify_id"] as! String
let durationMs = args["duration_ms"] as? Int64 ?? 0
let outputPath = args["output_path"] as! String
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "reEnrichFile":
let args = call.arguments as! [String: Any]
let requestJson = args["request_json"] as? String ?? "{}"
let response = GobackendReEnrichFile(requestJson, &error)
if let error = error { throw error }
return response
case "readFileMetadata":
let args = call.arguments as! [String: Any]
@@ -479,12 +519,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "downloadWithExtensions":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
if let error = error { throw error }
return response
case "enrichTrackWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -492,6 +526,12 @@ import Gobackend // Import Go framework
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
if let error = error { throw error }
return response
case "downloadWithExtensions":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
if let error = error { throw error }
return response
case "removeExtension":
let args = call.arguments as! [String: Any]
@@ -754,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",
+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.5';
static const String buildNumber = '79';
static const String version = '3.6.7';
static const String buildNumber = '81';
static const String fullVersion = '$version+$buildNumber';
+12
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:
File diff suppressed because it is too large Load Diff
+7
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';
File diff suppressed because it is too large Load Diff
+92 -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';
+7
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';
+113 -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';
@@ -1941,27 +1948,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 +2206,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 +2318,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 +2596,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 +2818,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 +2942,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';
}
+7
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 => 'フォルダ構成';
+19 -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';
+7
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';
File diff suppressed because it is too large Load Diff
+234 -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,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
'YouTube обеспечивает только звук с потерями(Lossy).';
@override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1967,7 +1976,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 +1985,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 +2079,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 +2241,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 +2324,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 +2579,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 +2766,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 +2781,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 +2820,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 +2835,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 +2887,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 +2906,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 +2918,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 +2940,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 +2970,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
+7
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';
File diff suppressed because it is too large Load Diff
+1246 -234
View File
File diff suppressed because it is too large Load Diff
+8
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"},
+1276 -11
View File
File diff suppressed because it is too large Load Diff
+1107 -95
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+856 -221
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1041 -29
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1316 -51
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+1028 -16
View File
File diff suppressed because it is too large Load Diff
+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
+67 -16
View File
@@ -21,6 +21,7 @@ class AppSettings {
final String folderOrganization;
final bool useAlbumArtistForFolders;
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
final bool filterContributingArtistsInAlbumArtist;
final String historyViewMode;
final String historyFilterMode;
final bool askQualityBeforeDownload;
@@ -36,18 +37,36 @@ class AppSettings {
final bool showExtensionStore;
final String locale;
final String lyricsMode;
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
autoExportFailedDownloads; // Auto export failed downloads to TXT file
final String
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
// Local Library Settings
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks
final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
// Tutorial/Onboarding
final bool hasCompletedTutorial; // Track if user has completed the app tutorial
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',
@@ -67,6 +86,7 @@ class AppSettings {
this.folderOrganization = 'none',
this.useAlbumArtistForFolders = true,
this.usePrimaryArtistOnly = false,
this.filterContributingArtistsInAlbumArtist = false,
this.historyViewMode = 'grid',
this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true,
@@ -92,6 +112,12 @@ 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 = true,
this.musixmatchLanguage = '',
});
AppSettings copyWith({
@@ -112,6 +138,7 @@ class AppSettings {
String? folderOrganization,
bool? useAlbumArtistForFolders,
bool? usePrimaryArtistOnly,
bool? filterContributingArtistsInAlbumArtist,
String? historyViewMode,
String? historyFilterMode,
bool? askQualityBeforeDownload,
@@ -138,6 +165,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,
@@ -157,18 +190,25 @@ class AppSettings {
folderOrganization: folderOrganization ?? this.folderOrganization,
useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
usePrimaryArtistOnly:
usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
filterContributingArtistsInAlbumArtist ??
this.filterContributingArtistsInAlbumArtist,
historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
askQualityBeforeDownload:
askQualityBeforeDownload ?? this.askQualityBeforeDownload,
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
useCustomSpotifyCredentials:
useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
metadataSource: metadataSource ?? this.metadataSource,
enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
useExtensionProviders:
useExtensionProviders ?? this.useExtensionProviders,
searchProvider: clearSearchProvider
? null
: (searchProvider ?? this.searchProvider),
separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
@@ -176,14 +216,25 @@ class AppSettings {
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
autoExportFailedDownloads:
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
// Local Library
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
localLibraryShowDuplicates:
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,
);
}
+65 -43
View File
@@ -24,6 +24,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
folderOrganization: json['folderOrganization'] as String? ?? 'none',
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
filterContributingArtistsInAlbumArtist:
json['filterContributingArtistsInAlbumArtist'] as bool? ?? false,
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
@@ -51,48 +53,68 @@ 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? ?? true,
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,
'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,
'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,
};
+225 -87
View File
@@ -323,7 +323,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (item.downloadTreeUri == null || item.downloadTreeUri!.isEmpty) {
continue;
}
if (item.filePath.isEmpty || !isContentUri(item.filePath)) {
final hasFilePath = item.filePath.trim().isNotEmpty;
final hasSafFileName =
item.safFileName != null && item.safFileName!.trim().isNotEmpty;
if (!hasFilePath && !hasSafFileName) {
continue;
}
candidateIndexes.add(i);
@@ -344,52 +347,59 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
for (var c = 0; c < candidateIndexes.length; c++) {
final i = candidateIndexes[c];
final item = items[i];
final rawPath = item.filePath.trim();
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
final exists = await fileExists(item.filePath);
if (exists) {
final verified = item.copyWith(
safRepaired: true,
safFileName: item.safFileName ?? _fileNameFromUri(item.filePath),
);
updatedItems[i] = verified;
changed = true;
verifiedCount++;
await _db.upsert(verified.toJson());
} else {
final fallbackName =
item.safFileName ?? _fileNameFromUri(item.filePath);
if (fallbackName.isEmpty) {
_historyLog.w('Missing SAF filename for history item: ${item.id}');
if (isDirectSafUri) {
final exists = await fileExists(rawPath);
if (exists) {
final verified = item.copyWith(
safRepaired: true,
safFileName: item.safFileName ?? _fileNameFromUri(rawPath),
);
updatedItems[i] = verified;
changed = true;
verifiedCount++;
await _db.upsert(verified.toJson());
continue;
}
}
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: item.downloadTreeUri!,
relativeDir: item.safRelativeDir ?? '',
fileName: fallbackName,
);
final newUri = resolved['uri'] as String? ?? '';
if (newUri.isEmpty) continue;
var fallbackName = (item.safFileName ?? '').trim();
if (fallbackName.isEmpty && isDirectSafUri) {
fallbackName = _fileNameFromUri(rawPath);
}
if (fallbackName.isEmpty) {
_historyLog.w('Missing SAF filename for history item: ${item.id}');
continue;
}
final newRelativeDir = resolved['relative_dir'] as String?;
final updated = item.copyWith(
filePath: newUri,
safRelativeDir:
(newRelativeDir != null && newRelativeDir.isNotEmpty)
? newRelativeDir
: item.safRelativeDir,
safFileName: fallbackName,
safRepaired: true,
);
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: item.downloadTreeUri!,
relativeDir: item.safRelativeDir ?? '',
fileName: fallbackName,
);
final newUri = (resolved['uri'] as String? ?? '').trim();
if (newUri.isEmpty) continue;
updatedItems[i] = updated;
changed = true;
repairedCount++;
await _db.upsert(updated.toJson());
} catch (e) {
_historyLog.w('Failed to repair SAF URI: $e');
}
final newRelativeDir = resolved['relative_dir'] as String?;
final updated = item.copyWith(
filePath: newUri,
safRelativeDir:
(newRelativeDir != null && newRelativeDir.isNotEmpty)
? newRelativeDir
: item.safRelativeDir,
safFileName: fallbackName,
safRepaired: true,
);
updatedItems[i] = updated;
changed = true;
repairedCount++;
await _db.upsert(updated.toJson());
} catch (e) {
_historyLog.w('Failed to repair SAF URI: $e');
}
if ((c + 1) % _safRepairBatchSize == 0) {
@@ -421,19 +431,33 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
existing = state.getByIsrc(item.isrc!);
}
final mergedItem = existing == null
? item
: item.copyWith(
genre:
_normalizeOptionalString(item.genre) ??
_normalizeOptionalString(existing.genre),
label:
_normalizeOptionalString(item.label) ??
_normalizeOptionalString(existing.label),
copyright:
_normalizeOptionalString(item.copyright) ??
_normalizeOptionalString(existing.copyright),
);
if (existing != null) {
final updatedItems = state.items
.where((i) => i.id != existing!.id)
.toList();
updatedItems.insert(0, item);
updatedItems.insert(0, mergedItem);
state = state.copyWith(items: updatedItems);
_historyLog.d('Updated existing history entry: ${item.trackName}');
_historyLog.d('Updated existing history entry: ${mergedItem.trackName}');
} else {
state = state.copyWith(items: [item, ...state.items]);
_historyLog.d('Added new history entry: ${item.trackName}');
state = state.copyWith(items: [mergedItem, ...state.items]);
_historyLog.d('Added new history entry: ${mergedItem.trackName}');
}
_db.upsert(item.toJson()).catchError((e) {
_db.upsert(mergedItem.toJson()).catchError((e) {
_historyLog.e('Failed to save to database: $e');
});
}
@@ -1173,11 +1197,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
}) async {
String baseDir = state.outputDir;
final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist);
var folderArtist = useAlbumArtistForFolders
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
? normalizedAlbumArtist ?? track.artistName
: track.artistName;
if (useAlbumArtistForFolders &&
filterContributingArtistsInAlbumArtist &&
normalizedAlbumArtist != null) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
if (usePrimaryArtistOnly) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
@@ -1273,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,
);
@@ -1285,6 +1316,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return artist;
}
String _resolveAlbumArtistForMetadata(Track track, AppSettings settings) {
var albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
if (settings.filterContributingArtistsInAlbumArtist) {
albumArtist = _extractPrimaryArtist(albumArtist);
}
return albumArtist;
}
bool _isSafMode(AppSettings settings) {
return Platform.isAndroid &&
settings.storageMode == 'saf' &&
@@ -1309,10 +1349,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
}) async {
final normalizedAlbumArtist = _normalizeOptionalString(track.albumArtist);
var folderArtist = useAlbumArtistForFolders
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
? normalizedAlbumArtist ?? track.artistName
: track.artistName;
if (useAlbumArtistForFolders &&
filterContributingArtistsInAlbumArtist &&
normalizedAlbumArtist != null) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
if (usePrimaryArtistOnly) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
@@ -1728,6 +1775,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(
track,
settings,
);
if (!settings.useExtensionProviders) return;
@@ -1742,8 +1793,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'title': track.name,
'artist': track.artistName,
'album': track.albumName,
'album_artist':
_normalizeOptionalString(track.albumArtist) ?? track.artistName,
'album_artist': resolvedAlbumArtist,
'track_number': track.trackNumber ?? 1,
'disc_number': track.discNumber ?? 1,
'isrc': track.isrc ?? '',
@@ -1803,7 +1853,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Track _buildTrackForMetadataEmbedding(
Track baseTrack,
Map<String, dynamic> backendResult,
String? normalizedAlbumArtist,
String resolvedAlbumArtist,
) {
final backendTrackNum = _parsePositiveInt(backendResult['track_number']);
final backendDiscNum = _parsePositiveInt(backendResult['disc_number']);
@@ -1826,7 +1876,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
name: baseTrack.name,
artistName: baseTrack.artistName,
albumName: backendAlbum ?? baseTrack.albumName,
albumArtist: normalizedAlbumArtist,
albumArtist: resolvedAlbumArtist,
coverUrl: baseTrack.coverUrl,
duration: baseTrack.duration,
isrc: baseTrack.isrc,
@@ -1890,16 +1940,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName,
};
final albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
if (track.trackNumber != null && track.trackNumber! > 0) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
metadata['TRACK'] = track.trackNumber.toString();
}
if (track.discNumber != null) {
if (track.discNumber != null && track.discNumber! > 0) {
metadata['DISCNUMBER'] = track.discNumber.toString();
metadata['DISC'] = track.discNumber.toString();
}
@@ -2033,16 +2082,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName,
};
final albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
if (track.trackNumber != null && track.trackNumber! > 0) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
metadata['TRACK'] = track.trackNumber.toString();
}
if (track.discNumber != null) {
if (track.discNumber != null && track.discNumber! > 0) {
metadata['DISCNUMBER'] = track.discNumber.toString();
metadata['DISC'] = track.discNumber.toString();
}
@@ -2198,15 +2246,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'ALBUM': track.albumName,
};
final albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
final albumArtist = _resolveAlbumArtistForMetadata(track, settings);
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
if (track.trackNumber != null && track.trackNumber! > 0) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
}
if (track.discNumber != null) {
if (track.discNumber != null && track.discNumber! > 0) {
metadata['DISCNUMBER'] = track.discNumber.toString();
}
@@ -2442,6 +2489,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await musicDir.create(recursive: true);
}
state = state.copyWith(outputDir: musicDir.path);
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
} else if (!isValidIosWritablePath(state.outputDir)) {
// Check for other invalid paths (like container root without Documents/)
_log.w(
@@ -2451,6 +2499,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final correctedPath = await validateOrFixIosPath(state.outputDir);
_log.i('Corrected path: $correctedPath');
state = state.copyWith(outputDir: correctedPath);
ref.read(settingsProvider.notifier).setDownloadDirectory(correctedPath);
}
}
@@ -2717,8 +2766,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
final normalizedAlbumArtist = _normalizeOptionalString(
trackToDownload.albumArtist,
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(
trackToDownload,
settings,
);
final quality = item.qualityOverride ?? state.audioQuality;
@@ -2731,6 +2781,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
)
: '';
String? appOutputDir;
@@ -2743,6 +2795,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
);
var effectiveOutputDir = initialOutputDir;
var effectiveSafMode = isSafMode;
@@ -2759,6 +2813,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;
@@ -2768,6 +2823,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? genre;
String? label;
String? copyright;
String? deezerTrackId = trackToDownload.deezerId;
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
@@ -2845,9 +2901,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
(trackToDownload.isrc == null && deezerIsrc != null) ||
(!_isValidISRC(trackToDownload.isrc ?? '') &&
deezerIsrc != null) ||
(trackToDownload.trackNumber == null &&
deezerTrackNum != null) ||
(trackToDownload.discNumber == null && deezerDiscNum != null);
((trackToDownload.trackNumber == null ||
trackToDownload.trackNumber! <= 0) &&
deezerTrackNum != null &&
deezerTrackNum > 0) ||
((trackToDownload.discNumber == null ||
trackToDownload.discNumber! <= 0) &&
deezerDiscNum != null &&
deezerDiscNum > 0);
if (needsEnrich) {
trackToDownload = Track(
@@ -2861,8 +2922,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
? deezerIsrc
: trackToDownload.isrc,
trackNumber: trackToDownload.trackNumber ?? deezerTrackNum,
discNumber: trackToDownload.discNumber ?? deezerDiscNum,
trackNumber:
(trackToDownload.trackNumber != null &&
trackToDownload.trackNumber! > 0)
? trackToDownload.trackNumber
: deezerTrackNum,
discNumber:
(trackToDownload.discNumber != null &&
trackToDownload.discNumber! > 0)
? trackToDownload.discNumber
: deezerDiscNum,
releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate,
deezerId: deezerTrackId,
availability: trackToDownload.availability,
@@ -2889,8 +2958,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (extendedMetadata != null) {
genre = extendedMetadata['genre'];
label = extendedMetadata['label'];
copyright = extendedMetadata['copyright'];
if (genre != null && genre.isNotEmpty) {
_log.d('Extended metadata - Genre: $genre, Label: $label');
_log.d(
'Extended metadata - Genre: $genre, Label: $label, Copyright: $copyright',
);
}
}
} catch (e) {
@@ -2937,6 +3009,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
_log.d('Output dir: $outputDir');
final normalizedTrackNumber =
(trackToDownload.trackNumber != null &&
trackToDownload.trackNumber! > 0)
? trackToDownload.trackNumber!
: 1;
final normalizedDiscNumber =
(trackToDownload.discNumber != null &&
trackToDownload.discNumber! > 0)
? trackToDownload.discNumber!
: 1;
final payload = DownloadRequestPayload(
isrc: trackToDownload.isrc ?? '',
service: item.service,
@@ -2944,7 +3027,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: normalizedAlbumArtist ?? trackToDownload.artistName,
albumArtist: resolvedAlbumArtist,
coverUrl: trackToDownload.coverUrl ?? '',
outputDir: outputDir,
filenameFormat: state.filenameFormat,
@@ -2952,14 +3035,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Keep prior behavior: non-YouTube paths were implicitly true.
embedLyrics: isYouTube ? settings.embedLyrics : true,
embedMaxQualityCover: settings.maxQualityCover,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
trackNumber: normalizedTrackNumber,
discNumber: normalizedDiscNumber,
releaseDate: trackToDownload.releaseDate ?? '',
itemId: item.id,
durationMs: trackToDownload.duration,
source: trackToDownload.source ?? '',
genre: genre ?? '',
label: label ?? '',
copyright: copyright ?? '',
deezerId: deezerTrackId ?? '',
lyricsMode: settings.lyricsMode,
storageMode: storageMode,
@@ -2992,6 +3076,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
);
final fallbackResult = await runDownload(
useSaf: false,
@@ -3329,7 +3415,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
@@ -3493,7 +3579,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
@@ -3553,7 +3639,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
@@ -3613,7 +3699,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
@@ -3650,7 +3736,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
@@ -3748,6 +3834,47 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
// SAF downloads should end with content URI. If we still have a
// transient FD path, recover URI from SAF metadata to keep history
// dedup/exclusion stable.
if (effectiveSafMode &&
filePath != null &&
filePath.isNotEmpty &&
!isContentUri(filePath) &&
settings.downloadTreeUri.isNotEmpty) {
final fallbackName = (finalSafFileName ?? safFileName ?? '').trim();
if (fallbackName.isNotEmpty) {
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: fallbackName,
);
final resolvedUri = (resolved['uri'] as String? ?? '').trim();
final resolvedRelativeDir =
(resolved['relative_dir'] as String? ?? '').trim();
if (resolvedUri.isNotEmpty && isContentUri(resolvedUri)) {
_log.w('Recovered SAF URI from transient path: $filePath');
filePath = resolvedUri;
finalSafFileName = fallbackName;
if (resolvedRelativeDir.isNotEmpty) {
effectiveOutputDir = resolvedRelativeDir;
}
} else {
_log.w(
'Failed to recover SAF URI (fileName=$fallbackName, dir=$effectiveOutputDir)',
);
}
} catch (e) {
_log.w('SAF URI recovery failed: $e');
}
} else {
_log.w(
'SAF download returned non-URI path without filename metadata: $filePath',
);
}
}
updateItemStatus(
item.id,
DownloadStatus.completed,
@@ -3840,13 +3967,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
final effectiveGenre =
_normalizeOptionalString(backendGenre) ??
_normalizeOptionalString(genre) ??
_normalizeOptionalString(existingInHistory?.genre);
final effectiveLabel =
_normalizeOptionalString(backendLabel) ??
_normalizeOptionalString(label) ??
_normalizeOptionalString(existingInHistory?.label);
final effectiveCopyright =
_normalizeOptionalString(backendCopyright) ??
_normalizeOptionalString(copyright) ??
_normalizeOptionalString(existingInHistory?.copyright);
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
final historyAlbumArtist =
(normalizedAlbumArtist != null &&
normalizedAlbumArtist != trackToDownload.artistName)
? normalizedAlbumArtist
resolvedAlbumArtist != trackToDownload.artistName
? resolvedAlbumArtist
: null;
final isMp3 = filePath.endsWith('.mp3');
@@ -3899,9 +4037,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
quality: actualQuality,
bitDepth: historyBitDepth,
sampleRate: historySampleRate,
genre: backendGenre,
label: backendLabel,
copyright: backendCopyright,
genre: effectiveGenre,
label: effectiveLabel,
copyright: effectiveCopyright,
),
);
+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,
+169 -4
View File
@@ -3,8 +3,10 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -116,6 +118,7 @@ class LocalLibraryState {
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance;
final NotificationService _notificationService = NotificationService();
static const _progressPollingInterval = Duration(milliseconds: 800);
Timer? _progressTimer;
bool _isLoaded = false;
@@ -180,6 +183,58 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await _loadFromDatabase();
}
Set<String> _buildPathMatchKeys(String? filePath) {
final raw = filePath?.trim() ?? '';
if (raw.isEmpty) return const {};
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
final keys = <String>{cleaned};
void addNormalized(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return;
keys.add(trimmed);
keys.add(trimmed.toLowerCase());
if (trimmed.contains('\\')) {
final slash = trimmed.replaceAll('\\', '/');
keys.add(slash);
keys.add(slash.toLowerCase());
}
if (trimmed.contains('%')) {
try {
final decoded = Uri.decodeFull(trimmed);
keys.add(decoded);
keys.add(decoded.toLowerCase());
} catch (_) {}
}
}
addNormalized(cleaned);
if (cleaned.startsWith('content://')) {
try {
final uri = Uri.parse(cleaned);
addNormalized(uri.toString());
addNormalized(uri.replace(query: null, fragment: null).toString());
} catch (_) {}
}
return keys;
}
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
return false;
}
final candidateKeys = _buildPathMatchKeys(filePath);
for (final key in candidateKeys) {
if (downloadedPathKeys.contains(key)) {
return true;
}
}
return false;
}
Future<void> startScan(
String folderPath, {
bool forceFullScan = false,
@@ -202,6 +257,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanErrorCount: 0,
scanWasCancelled: false,
);
await _showScanProgressNotification(
progress: 0,
scannedFiles: 0,
totalFiles: 0,
currentFile: null,
);
try {
final appSupportDir = await getApplicationSupportDirectory();
@@ -217,10 +278,26 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final isSaf = folderPath.startsWith('content://');
// Get all file paths from download history to exclude them
// Get all file paths from download history to exclude them.
// Merge DB + in-memory state to avoid race when a fresh download has not
// been flushed to SQLite yet.
final downloadedPaths = await _historyDb.getAllFilePaths();
final inMemoryHistoryPaths = ref
.read(downloadHistoryProvider)
.items
.map((item) => item.filePath)
.where((path) => path.isNotEmpty);
final allHistoryPaths = <String>{
...downloadedPaths,
...inMemoryHistoryPaths,
};
final downloadedPathKeys = <String>{};
for (final path in allHistoryPaths) {
downloadedPathKeys.addAll(_buildPathMatchKeys(path));
}
_log.i(
'Excluding ${downloadedPaths.length} downloaded files from library scan',
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
'(${downloadedPathKeys.length} path keys)',
);
if (forceFullScan) {
@@ -230,6 +307,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
: await PlatformBridge.scanLibraryFolder(folderPath);
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
await _showScanCancelledNotification();
return;
}
@@ -238,7 +316,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
for (final json in results) {
final filePath = json['filePath'] as String?;
// Skip files that are already in download history
if (filePath != null && downloadedPaths.contains(filePath)) {
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
skippedDownloads++;
continue;
}
@@ -275,6 +353,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'Full scan complete: ${items.length} tracks found, '
'$skippedDownloads already in downloads',
);
await _showScanCompleteNotification(
totalTracks: items.length,
excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount,
);
} else {
// Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes();
@@ -308,6 +391,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
await _showScanCancelledNotification();
return;
}
@@ -344,7 +428,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
for (final json in scannedList) {
final map = json as Map<String, dynamic>;
final filePath = map['filePath'] as String?;
if (filePath != null && downloadedPaths.contains(filePath)) {
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
skippedDownloads++;
continue;
}
@@ -399,10 +483,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'(${scannedList.length} new/updated, $skippedCount unchanged, '
'${deletedPaths.length} removed, $skippedDownloads already in downloads)',
);
await _showScanCompleteNotification(
totalTracks: items.length,
excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount,
);
}
} catch (e, stack) {
_log.e('Library scan failed: $e', e, stack);
state = state.copyWith(isScanning: false, scanWasCancelled: false);
await _showScanFailedNotification(e.toString());
} finally {
_stopProgressPolling();
}
@@ -441,6 +531,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scannedFiles: scannedFiles,
scanErrorCount: errorCount,
);
await _showScanProgressNotification(
progress: normalizedProgress,
scannedFiles: scannedFiles,
totalFiles: totalFiles,
currentFile: currentFile,
);
}
if (progress['is_complete'] == true) {
@@ -473,6 +569,75 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await PlatformBridge.cancelLibraryScan();
state = state.copyWith(isScanning: false, scanWasCancelled: true);
_stopProgressPolling();
await _showScanCancelledNotification();
}
Future<void> _showScanProgressNotification({
required double progress,
required int scannedFiles,
required int totalFiles,
required String? currentFile,
}) async {
try {
await _notificationService.showLibraryScanProgress(
progress: progress,
scannedFiles: scannedFiles,
totalFiles: totalFiles,
currentFile: _shortenFileForNotification(currentFile),
);
} catch (e) {
_log.w('Failed to show scan progress notification: $e');
}
}
Future<void> _showScanCompleteNotification({
required int totalTracks,
required int excludedDownloadedCount,
required int errorCount,
}) async {
try {
await _notificationService.showLibraryScanComplete(
totalTracks: totalTracks,
excludedDownloadedCount: excludedDownloadedCount,
errorCount: errorCount,
);
} catch (e) {
_log.w('Failed to show scan complete notification: $e');
}
}
Future<void> _showScanFailedNotification(String message) async {
try {
await _notificationService.showLibraryScanFailed(message);
} catch (e) {
_log.w('Failed to show scan failure notification: $e');
}
}
Future<void> _showScanCancelledNotification() async {
try {
await _notificationService.showLibraryScanCancelled();
} catch (e) {
_log.w('Failed to show scan cancelled notification: $e');
}
}
String? _shortenFileForNotification(String? path) {
final raw = path?.trim() ?? '';
if (raw.isEmpty) return null;
var decoded = raw;
try {
decoded = Uri.decodeFull(raw);
} catch (_) {}
final slashIdx = decoded.lastIndexOf('/');
final backslashIdx = decoded.lastIndexOf('\\');
final cut = slashIdx > backslashIdx ? slashIdx : backslashIdx;
if (cut >= 0 && cut < decoded.length - 1) {
return decoded.substring(cut + 1);
}
return decoded;
}
Future<int> cleanupMissingFiles() async {
+52
View File
@@ -39,6 +39,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 {
@@ -188,6 +205,36 @@ 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();
@@ -236,6 +283,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setFilterContributingArtistsInAlbumArtist(bool enabled) {
state = state.copyWith(filterContributingArtistsInAlbumArtist: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode);
_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,
),
+15 -1
View File
@@ -560,7 +560,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
if (tracks.isEmpty) return null;
final first = tracks.first;
if (first.bitDepth == null || first.sampleRate == null) return null;
// For lossy formats, use bitrate
if (first.bitrate != null && first.bitrate! > 0) {
final fmt = first.format?.toUpperCase() ?? '';
final firstBitrate = first.bitrate;
for (final track in tracks) {
if (track.bitrate != firstBitrate) {
return null;
}
}
return '$fmt ${firstBitrate}kbps'.trim();
}
// For lossless formats, use bit depth / sample rate
if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) return null;
final firstQuality =
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
+10 -2
View File
@@ -70,7 +70,12 @@ class UnifiedLibraryItem {
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
String? quality;
if (item.bitDepth != null && item.sampleRate != null) {
if (item.bitrate != null && item.bitrate! > 0) {
// Lossy format with bitrate
final fmt = item.format?.toUpperCase() ?? '';
quality = '$fmt ${item.bitrate}kbps'.trim();
} else if (item.bitDepth != null && item.bitDepth! > 0 && item.sampleRate != null) {
// Lossless format with actual bit depth
quality =
'${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
}
@@ -897,7 +902,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
String? _localQualityLabel(LocalLibraryItem item) {
if (item.bitDepth == null || item.sampleRate == null) {
if (item.bitrate != null && item.bitrate! > 0) {
return '${item.bitrate}kbps';
}
if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) {
return null;
}
return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
+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,
),
],
+20 -12
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,
),
],
),
),
),
],
),
),
@@ -205,6 +212,7 @@ class _RecentDonorsCard extends StatelessWidget {
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
_DonorTile(name: '283Fabio', colorScheme: colorScheme),
_DonorTile(name: 'laflame', colorScheme: colorScheme),
_DonorTile(
name: 'Elias el Autentico',
colorScheme: colorScheme,
@@ -414,9 +422,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),
),
),
],
+394 -134
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 {
@@ -25,6 +26,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false;
bool _artistFolderFiltersExpanded = false;
@override
void initState() {
@@ -278,6 +280,62 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
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,
),
],
@@ -363,19 +421,53 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUseAlbumArtistForFolders(value),
showDivider: false,
),
SettingsItem(
icon: Icons.filter_alt_outlined,
title: 'Artist Name Filters',
subtitle: _getArtistFolderFilterSubtitle(
context,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterAlbumArtistContributors:
settings.filterContributingArtistsInAlbumArtist,
),
SettingsSwitchItem(
icon: Icons.person_outline,
title: context.l10n.downloadUsePrimaryArtistOnly,
subtitle: settings.usePrimaryArtistOnly
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
value: settings.usePrimaryArtistOnly,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsePrimaryArtistOnly(value),
showDivider: false,
trailing: Icon(
_artistFolderFiltersExpanded
? Icons.expand_less
: Icons.expand_more,
),
onTap: () {
setState(() {
_artistFolderFiltersExpanded =
!_artistFolderFiltersExpanded;
});
},
showDivider: !_artistFolderFiltersExpanded,
),
if (_artistFolderFiltersExpanded)
SettingsSwitchItem(
icon: Icons.person_outline,
title: context.l10n.downloadUsePrimaryArtistOnly,
subtitle: settings.usePrimaryArtistOnly
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
value: settings.usePrimaryArtistOnly,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsePrimaryArtistOnly(value),
),
if (_artistFolderFiltersExpanded)
SettingsSwitchItem(
icon: Icons.group_remove_outlined,
title: 'Filter contributing artists in Album Artist',
subtitle: settings.filterContributingArtistsInAlbumArtist
? 'Album Artist metadata uses primary artist only'
: 'Keep full Album Artist metadata value',
value: settings.filterContributingArtistsInAlbumArtist,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setFilterContributingArtistsInAlbumArtist(value),
showDivider: false,
),
],
),
@@ -585,14 +677,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;
@@ -624,130 +730,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),
],
),
),
),
),
@@ -937,7 +1077,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(validation.errorReason ?? context.l10n.setupIcloudNotSupported),
content: Text(
validation.errorReason ??
context.l10n.setupIcloudNotSupported,
),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
@@ -1000,6 +1143,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
}
}
String _getArtistFolderFilterSubtitle(
BuildContext context, {
required bool usePrimaryArtistOnly,
required bool filterAlbumArtistContributors,
}) {
final statuses = <String>[
usePrimaryArtistOnly ? 'Primary only: On' : 'Primary only: Off',
filterAlbumArtistContributors
? 'Album Artist metadata: Primary only'
: 'Album Artist metadata: Full',
];
return statuses.join(' | ');
}
String _getLyricsModeLabel(BuildContext context, String mode) {
switch (mode) {
case 'external':
@@ -1083,6 +1240,111 @@ 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 _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':
@@ -1456,9 +1718,7 @@ class _ServiceChip extends StatelessWidget {
return Expanded(
child: Material(
color: isSelected
? colorScheme.primaryContainer
: unselectedColor,
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
@@ -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,
});
}
+11 -2
View File
@@ -68,10 +68,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _checkInitialPermissions() async {
if (Platform.isIOS) {
final notificationStatus = await Permission.notification.status;
if (mounted) {
setState(() {
_storagePermissionGranted = true;
_notificationPermissionGranted = true;
_notificationPermissionGranted =
notificationStatus.isGranted || notificationStatus.isProvisional;
});
}
} else if (Platform.isAndroid) {
@@ -181,7 +183,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _requestNotificationPermission() async {
setState(() => _isLoading = true);
try {
if (_androidSdkVersion >= 33) {
if (Platform.isIOS) {
final status = await Permission.notification.request();
if (status.isGranted || status.isProvisional) {
setState(() => _notificationPermissionGranted = true);
} else if (status.isPermanentlyDenied) {
await _showPermissionDeniedDialog('Notification');
}
} else if (_androidSdkVersion >= 33) {
final status = await Permission.notification.request();
if (status.isGranted) {
setState(() => _notificationPermissionGranted = true);
+171 -33
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,11 @@ 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 const List<String> _months = [
'Jan',
'Feb',
@@ -416,6 +423,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
int? get sampleRate =>
_isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null;
String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
@@ -424,8 +432,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_isLocalItem ? _localLibraryItem!.coverPath : null;
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
DateTime get _addedAt =>
_isLocalItem ? _localLibraryItem!.scannedAt : _downloadItem!.downloadedAt;
DateTime get _addedAt {
if (_isLocalItem) {
// Use file modification time if available, otherwise fall back to scannedAt
final modTime = _localLibraryItem!.fileModTime;
if (modTime != null && modTime > 0) {
return DateTime.fromMillisecondsSinceEpoch(modTime);
}
return _localLibraryItem!.scannedAt;
}
return _downloadItem!.downloadedAt;
}
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
String get cleanFilePath {
@@ -433,6 +451,50 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return path.startsWith('EXISTS:') ? path.substring(7) : path;
}
String _formatPathForDisplay(String pathOrUri) {
if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) {
return pathOrUri;
}
try {
final uri = Uri.parse(pathOrUri);
final segments = uri.pathSegments;
String? documentId;
final documentIndex = segments.indexOf('document');
if (documentIndex != -1 && documentIndex + 1 < segments.length) {
documentId = Uri.decodeComponent(segments[documentIndex + 1]);
}
if (documentId == null || documentId.isEmpty) {
final treeIndex = segments.indexOf('tree');
if (treeIndex != -1 && treeIndex + 1 < segments.length) {
documentId = Uri.decodeComponent(segments[treeIndex + 1]);
}
}
if (documentId == null || documentId.isEmpty) return pathOrUri;
final separatorIndex = documentId.indexOf(':');
if (separatorIndex <= 0) return documentId;
final volumeId = documentId.substring(0, separatorIndex);
final relativePath = documentId
.substring(separatorIndex + 1)
.replaceAll('\\', '/');
if (volumeId.toLowerCase() == 'primary') {
if (relativePath.isEmpty) return '/storage/emulated/0';
return '/storage/emulated/0/$relativePath';
}
if (relativePath.isEmpty) return volumeId;
return 'SD Card/$relativePath';
} catch (_) {
return pathOrUri;
}
}
void _markMetadataChanged() {
_hasMetadataChanges = true;
}
@@ -913,7 +975,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Determine audio quality string - prefer stored quality from download
String? audioQualityStr;
final fileName = _filePath.split('/').last;
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExt = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: '';
@@ -921,8 +983,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// Use stored quality from download history if available
if (_quality != null && _quality!.isNotEmpty) {
audioQualityStr = _quality;
} else if (bitDepth != null && sampleRate != null) {
// Fallback for FLAC files without stored quality
} else if (_isLocalItem && _localBitrate != null && _localBitrate! > 0) {
// Lossy local file with bitrate info
final fmt = _localLibraryItem!.format?.toUpperCase() ?? fileExt;
audioQualityStr = '$fmt ${_localBitrate}kbps';
} else if (bitDepth != null && bitDepth! > 0 && sampleRate != null) {
// Lossless file with actual bit depth (FLAC, ALAC)
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
} else {
@@ -1031,7 +1097,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool fileExists,
int? fileSize,
) {
final fileName = cleanFilePath.split(Platform.pathSeparator).last;
final displayFilePath = _formatPathForDisplay(cleanFilePath);
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
final fileExtension = fileName.contains('.')
? fileName.split('.').last.toUpperCase()
: 'Unknown';
@@ -1128,7 +1195,33 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
)
else if (bitDepth != null && sampleRate != null)
else if (_isLocalItem &&
_localBitrate != null &&
_localBitrate! > 0 &&
(fileExtension == 'MP3' ||
fileExtension == 'OPUS' ||
fileExtension == 'OGG'))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_localBitrate}kbps',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
)
else if (bitDepth != null &&
bitDepth! > 0 &&
sampleRate != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
@@ -1194,7 +1287,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
children: [
Expanded(
child: Text(
cleanFilePath,
displayFilePath,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: colorScheme.onSurfaceVariant,
@@ -1253,6 +1346,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)
@@ -1374,6 +1477,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_lyricsLoading = true;
_lyricsError = null;
_isInstrumental = false;
_lyricsSource = null;
});
try {
@@ -1382,20 +1486,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;
});
@@ -1405,43 +1520,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;
});
}
@@ -2127,17 +2254,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);
}
}
+14 -1
View File
@@ -23,6 +23,7 @@ class LocalLibraryItem {
final String? releaseDate;
final int? bitDepth;
final int? sampleRate;
final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg)
final String? genre;
final String? format; // flac, mp3, opus, m4a
@@ -43,6 +44,7 @@ class LocalLibraryItem {
this.releaseDate,
this.bitDepth,
this.sampleRate,
this.bitrate,
this.genre,
this.format,
});
@@ -64,6 +66,7 @@ class LocalLibraryItem {
'releaseDate': releaseDate,
'bitDepth': bitDepth,
'sampleRate': sampleRate,
'bitrate': bitrate,
'genre': genre,
'format': format,
};
@@ -86,6 +89,7 @@ class LocalLibraryItem {
releaseDate: json['releaseDate'] as String?,
bitDepth: json['bitDepth'] as int?,
sampleRate: json['sampleRate'] as int?,
bitrate: (json['bitrate'] as num?)?.toInt(),
genre: json['genre'] as String?,
format: json['format'] as String?,
);
@@ -115,7 +119,7 @@ class LibraryDatabase {
return await openDatabase(
path,
version: 3, // Bumped version for file_mod_time migration
version: 4, // Bumped version for bitrate column
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
@@ -142,6 +146,7 @@ class LibraryDatabase {
release_date TEXT,
bit_depth INTEGER,
sample_rate INTEGER,
bitrate INTEGER,
genre TEXT,
format TEXT
)
@@ -169,6 +174,12 @@ class LibraryDatabase {
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
_log.i('Added file_mod_time column for incremental scanning');
}
if (oldVersion < 4) {
// Add bitrate column for lossy format quality info
await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER');
_log.i('Added bitrate column for lossy format quality');
}
}
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
@@ -189,6 +200,7 @@ class LibraryDatabase {
'release_date': json['releaseDate'],
'bit_depth': json['bitDepth'],
'sample_rate': json['sampleRate'],
'bitrate': json['bitrate'],
'genre': json['genre'],
'format': json['format'],
};
@@ -212,6 +224,7 @@ class LibraryDatabase {
'releaseDate': row['release_date'],
'bitDepth': row['bit_depth'],
'sampleRate': row['sample_rate'],
'bitrate': row['bitrate'],
'genre': row['genre'],
'format': row['format'],
};
+276 -33
View File
@@ -1,27 +1,39 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
bool _notificationPermissionRequested = false;
static const int downloadProgressId = 1;
static const int updateDownloadId = 2;
static const int libraryScanId = 3;
static const String channelId = 'download_progress';
static const String channelName = 'Download Progress';
static const String channelDescription = 'Shows download progress for tracks';
static const String libraryChannelId = 'library_scan';
static const String libraryChannelName = 'Library Scan';
static const String libraryChannelDescription =
'Shows local library scan progress';
Future<void> initialize() async {
if (_isInitialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
@@ -33,24 +45,86 @@ class NotificationService {
await _notifications.initialize(settings: initSettings);
if (Platform.isAndroid) {
await _notifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(
const AndroidNotificationChannel(
channelId,
channelName,
description: channelDescription,
importance: Importance.low,
showBadge: false,
playSound: false,
enableVibration: false,
),
);
final androidImpl = _notifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
await androidImpl?.createNotificationChannel(
const AndroidNotificationChannel(
channelId,
channelName,
description: channelDescription,
importance: Importance.low,
showBadge: false,
playSound: false,
enableVibration: false,
),
);
await androidImpl?.createNotificationChannel(
const AndroidNotificationChannel(
libraryChannelId,
libraryChannelName,
description: libraryChannelDescription,
importance: Importance.low,
showBadge: false,
playSound: false,
enableVibration: false,
),
);
}
_isInitialized = true;
}
Future<bool> _ensureNotificationPermission() async {
if (!Platform.isIOS) return true;
final status = await Permission.notification.status;
if (status.isGranted || status.isProvisional) return true;
if (_notificationPermissionRequested ||
status.isPermanentlyDenied ||
status.isRestricted) {
return false;
}
_notificationPermissionRequested = true;
final requested = await Permission.notification.request();
return requested.isGranted || requested.isProvisional;
}
Future<void> _showSafely({
required int id,
required String title,
required String body,
required NotificationDetails details,
}) async {
if (!await _ensureNotificationPermission()) return;
try {
await _notifications.show(
id: id,
title: title,
body: body,
notificationDetails: details,
);
} on PlatformException catch (e) {
final isNotificationsNotAllowed =
Platform.isIOS &&
(e.code == 'Error 1' ||
(e.message?.contains('UNErrorDomain error 1') ?? false) ||
e.toString().contains('UNErrorDomain error 1'));
if (isNotificationsNotAllowed) {
debugPrint(
'iOS notifications not allowed; skipping local notification',
);
return;
}
rethrow;
}
}
Future<void> showDownloadProgress({
required String trackName,
required String artistName,
@@ -60,7 +134,7 @@ class NotificationService {
if (!_isInitialized) await initialize();
final percentage = total > 0 ? (progress * 100 ~/ total) : 0;
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
@@ -89,11 +163,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: downloadProgressId,
title: 'Downloading $trackName',
body: '$artistName$percentage%',
notificationDetails: details,
details: details,
);
}
@@ -132,11 +206,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: downloadProgressId,
title: 'Finalizing $trackName',
body: '$artistName • Embedding metadata...',
notificationDetails: details,
details: details,
);
}
@@ -182,11 +256,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: downloadProgressId,
title: title,
body: '$trackName - $artistName',
notificationDetails: details,
details: details,
);
}
@@ -222,11 +296,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: downloadProgressId,
title: title,
body: '$completedCount tracks downloaded successfully',
notificationDetails: details,
details: details,
);
}
@@ -234,6 +308,175 @@ class NotificationService {
await _notifications.cancel(id: downloadProgressId);
}
Future<void> showLibraryScanProgress({
required double progress,
required int scannedFiles,
required int totalFiles,
String? currentFile,
}) async {
if (!_isInitialized) await initialize();
final clampedProgress = progress.clamp(0.0, 100.0);
final percentage = clampedProgress.round();
final progressBody = totalFiles > 0
? '$scannedFiles/$totalFiles files • $percentage%'
: '$scannedFiles files scanned • $percentage%';
final body = (currentFile != null && currentFile.isNotEmpty)
? '$progressBody\n$currentFile'
: progressBody;
final androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.low,
priority: Priority.low,
showProgress: true,
maxProgress: 100,
progress: percentage,
ongoing: true,
autoCancel: false,
playSound: false,
enableVibration: false,
onlyAlertOnce: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: false,
presentBadge: false,
presentSound: false,
);
final details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: 'Scanning local library',
body: body,
details: details,
);
}
Future<void> showLibraryScanComplete({
required int totalTracks,
int excludedDownloadedCount = 0,
int errorCount = 0,
}) async {
if (!_isInitialized) await initialize();
final extras = <String>[];
if (excludedDownloadedCount > 0) {
extras.add('$excludedDownloadedCount excluded');
}
if (errorCount > 0) {
extras.add('$errorCount errors');
}
final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})';
const androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: 'Library scan complete',
body: '$totalTracks tracks indexed$suffix',
details: details,
);
}
Future<void> showLibraryScanFailed(String message) async {
if (!_isInitialized) await initialize();
const androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: 'Library scan failed',
body: message,
details: details,
);
}
Future<void> showLibraryScanCancelled() async {
if (!_isInitialized) await initialize();
const androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: 'Library scan cancelled',
body: 'Scan stopped before completion.',
details: details,
);
}
Future<void> cancelLibraryScanNotification() async {
await _notifications.cancel(id: libraryScanId);
}
Future<void> showUpdateDownloadProgress({
required String version,
required int received,
@@ -244,7 +487,7 @@ class NotificationService {
final percentage = total > 0 ? (received * 100 ~/ total) : 0;
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
@@ -273,11 +516,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: updateDownloadId,
title: 'Downloading SpotiFLAC v$version',
body: '$receivedMB / $totalMB MB • $percentage%',
notificationDetails: details,
details: details,
);
}
@@ -306,11 +549,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: updateDownloadId,
title: 'Update Ready',
body: 'SpotiFLAC v$version downloaded. Tap to install.',
notificationDetails: details,
details: details,
);
}
@@ -338,11 +581,11 @@ class NotificationService {
iOS: iosDetails,
);
await _notifications.show(
await _showSafely(
id: updateDownloadId,
title: 'Update Failed',
body: 'Could not download update. Try again later.',
notificationDetails: details,
details: details,
);
}
+59 -5
View File
@@ -103,8 +103,6 @@ class PlatformBridge {
return response;
}
static Future<Map<String, dynamic>> getDownloadProgress() async {
final result = await _channel.invokeMethod('getDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>;
@@ -278,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,
@@ -334,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 {
@@ -509,6 +565,7 @@ class PlatformBridge {
return {
'genre': data['genre'] as String? ?? '',
'label': data['label'] as String? ?? '',
'copyright': data['copyright'] as String? ?? '',
};
} catch (e) {
_log.w('Failed to get Deezer extended metadata for $trackId: $e');
@@ -719,8 +776,6 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<void> cleanupExtensions() async {
_log.d('cleanupExtensions');
await _channel.invokeMethod('cleanupExtensions');
@@ -1130,5 +1185,4 @@ class PlatformBridge {
}
// ==================== YOUTUBE / COBALT ====================
}
+76 -8
View File
@@ -12,6 +12,14 @@ final _iosContainerRootPattern = RegExp(
r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$',
caseSensitive: false,
);
final _iosContainerPathWithoutLeadingSlashPattern = RegExp(
r'^(private/)?var/mobile/Containers/Data/Application/[A-F0-9\-]+/.+',
caseSensitive: false,
);
final _iosLegacyRelativeDocumentsPattern = RegExp(
r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
caseSensitive: false,
);
/// Checks if a path is a valid writable directory on iOS.
/// Returns false if:
@@ -21,6 +29,7 @@ final _iosContainerRootPattern = RegExp(
bool isValidIosWritablePath(String path) {
if (!Platform.isIOS) return true;
if (path.isEmpty) return false;
if (!path.startsWith('/')) return false;
// Check if it's the container root (without Documents/, tmp/, etc.)
if (_iosContainerRootPattern.hasMatch(path)) {
@@ -54,16 +63,64 @@ bool isValidIosWritablePath(String path) {
/// Validates and potentially corrects an iOS path.
/// Returns a valid Documents subdirectory path if the input is invalid.
Future<String> validateOrFixIosPath(String path, {String subfolder = 'SpotiFLAC'}) async {
Future<String> validateOrFixIosPath(
String path, {
String subfolder = 'SpotiFLAC',
}) async {
if (!Platform.isIOS) return path;
if (isValidIosWritablePath(path)) {
return path;
final trimmed = path.trim();
if (isValidIosWritablePath(trimmed)) {
return trimmed;
}
final docDir = await getApplicationDocumentsDirectory();
final candidates = <String>[];
if (trimmed.isNotEmpty) {
candidates.add(trimmed);
}
// Some pickers can return absolute iOS paths without the leading slash.
if (_iosContainerPathWithoutLeadingSlashPattern.hasMatch(trimmed)) {
candidates.add('/$trimmed');
}
// Recover legacy relative iOS path format:
// Data/Application/<UUID>/Documents/<subdir>
final legacyRelativeMatch = _iosLegacyRelativeDocumentsPattern.firstMatch(
trimmed,
);
if (legacyRelativeMatch != null) {
final suffix = (legacyRelativeMatch.group(1) ?? '').trim();
final normalizedSuffix = suffix.startsWith('/')
? suffix.substring(1)
: suffix;
candidates.add(
normalizedSuffix.isEmpty
? docDir.path
: '${docDir.path}/$normalizedSuffix',
);
}
// Generic salvage for relative paths containing `Documents/...`.
if (!trimmed.startsWith('/')) {
final documentsMarker = 'Documents/';
final index = trimmed.indexOf(documentsMarker);
if (index >= 0) {
final suffix = trimmed.substring(index + documentsMarker.length).trim();
candidates.add(suffix.isEmpty ? docDir.path : '${docDir.path}/$suffix');
}
}
for (final candidate in candidates) {
if (isValidIosWritablePath(candidate)) {
return candidate;
}
}
// Fall back to app Documents directory
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.path}/$subfolder');
final musicDir = Directory('${docDir.path}/$subfolder');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
@@ -96,11 +153,20 @@ IosPathValidationResult validateIosPath(String path) {
);
}
if (!path.startsWith('/')) {
return const IosPathValidationResult(
isValid: false,
errorReason:
'Invalid path format. Please choose a local folder from Files.',
);
}
// Check if it's the container root
if (_iosContainerRootPattern.hasMatch(path)) {
return const IosPathValidationResult(
isValid: false,
errorReason: 'Cannot write to app container root. Please choose a subfolder like Documents.',
errorReason:
'Cannot write to app container root. Please choose a subfolder like Documents.',
);
}
@@ -110,7 +176,8 @@ IosPathValidationResult validateIosPath(String path) {
path.contains('com~apple~CloudDocs')) {
return const IosPathValidationResult(
isValid: false,
errorReason: 'iCloud Drive is not supported. Please choose a local folder.',
errorReason:
'iCloud Drive is not supported. Please choose a local folder.',
);
}
@@ -125,7 +192,8 @@ IosPathValidationResult validateIosPath(String path) {
if (remainingPath.isEmpty || remainingPath == '/') {
return const IosPathValidationResult(
isValid: false,
errorReason: 'Cannot write to app container root. Please use the default folder or choose a different location.',
errorReason:
'Cannot write to app container root. Please use the default folder or choose a different location.',
);
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.6.5+79
version: 3.6.7+81
environment:
sdk: ^3.10.0
@@ -42,7 +42,7 @@ dependencies:
# Material Expressive 3 / Dynamic Color
dynamic_color: ^1.7.0
material_color_utilities: ^0.11.1
material_color_utilities: ">=0.11.1 <0.14.0"
# Permissions
permission_handler: ^12.0.1
+5871
View File
File diff suppressed because one or more lines are too long
+738
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 811 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+716
View File
File diff suppressed because one or more lines are too long
+785
View File
File diff suppressed because one or more lines are too long