Compare commits
205 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ac9ff1dd7 | |||
| 3e90b29d2b | |||
| b74186464b | |||
| f4934dcb28 | |||
| 30973a8e78 | |||
| 9b89625660 | |||
| c70ba5962e | |||
| 1407018d98 | |||
| 0dc89cf569 | |||
| 3c1e9d03a0 | |||
| 28a082f47a | |||
| 38994d5900 | |||
| 472896328a | |||
| 92f408035a | |||
| 979186243c | |||
| ee66247bea | |||
| 66a9daf733 | |||
| 69a9e0cb40 | |||
| cd6beaa7d4 | |||
| 5f4ff17630 | |||
| 3c3bbe516e | |||
| a1d1ab1f0f | |||
| ab9456fff8 | |||
| 2f673469aa | |||
| 05fde22075 | |||
| deab7b7dd6 | |||
| ae5da3b6e0 | |||
| 4d0c8f49aa | |||
| 3068f4e367 | |||
| 3844704490 | |||
| 12144b8220 | |||
| b639080494 | |||
| e67d7d68cb | |||
| b8f18c1cf5 | |||
| 529958c4af | |||
| 40077a577c | |||
| e0fbd706ce | |||
| b76879f204 | |||
| abc599d7f9 | |||
| eefbb63299 | |||
| fdbb474763 | |||
| 6a7eef6956 | |||
| 9b27e86e0f | |||
| dbe8f5d814 | |||
| 9847594ca1 | |||
| 986f5eafc8 | |||
| 84df64fcfe | |||
| a9150b85b9 | |||
| 68e6c8be35 | |||
| bd42655c0e | |||
| fe1c96ea12 | |||
| bae2bf63eb | |||
| 803e0dc5a3 | |||
| 474c37ec8e | |||
| eb7726263a | |||
| f87ccc51c5 | |||
| b0b4e7803c | |||
| 450f19c656 | |||
| 55b9c08f99 | |||
| a5f3aab775 | |||
| 7442c9b106 | |||
| ae66cb478b | |||
| 2516c3e618 | |||
| 02a5893279 | |||
| bd0d653210 | |||
| 62626ddc08 | |||
| b6574f0097 | |||
| c35a8dd803 | |||
| d54b2249b6 | |||
| f7be2c1e12 | |||
| ebe7d87da7 | |||
| 3a6b7eed59 | |||
| 51d02d7764 | |||
| df39d61ed4 | |||
| 9cd2b1d8c5 | |||
| 49f1fb43fa | |||
| 7ec5d28caf | |||
| 23f5aa11b0 | |||
| 5fdf1df5df | |||
| 65b521ff8b | |||
| 6d578694e2 | |||
| f7ec649b24 | |||
| 71a9e1baef | |||
| 4a4adcb72e | |||
| 3458f03158 | |||
| 4fe4a01840 | |||
| e5d6fddeda | |||
| 370f5e3b8b | |||
| f5bb0820d5 | |||
| feb6da3ecb | |||
| 39f28a12aa | |||
| 416fc79637 | |||
| 1f43780bec | |||
| f9dd82010f | |||
| f0790b627d | |||
| 55350fffa0 | |||
| 7229602343 | |||
| 1c81c53699 | |||
| 5256d6197b | |||
| 79a6c8cdc0 | |||
| aa3b4d7d1e | |||
| cd220a4650 | |||
| d71b2a9ab8 | |||
| a2efe7243d | |||
| e0acda14e4 | |||
| 029ab8ea47 | |||
| 38f9498006 | |||
| 67fc3e5de2 | |||
| f1e6e9253f | |||
| 11c612e270 | |||
| cec5e49659 | |||
| 1dbdb5f2c3 | |||
| 086511d3e9 | |||
| 3d366d21b7 | |||
| 35f412dbd2 | |||
| c167aa0522 | |||
| fccb3f3d78 | |||
| 3a33283e94 | |||
| c74fb28a3a | |||
| ea504cc3ed | |||
| 61a2ad258e | |||
| ab62a8b1a9 | |||
| 479eb1272d | |||
| d23562e579 | |||
| 541d64bdd0 | |||
| d4f7e6e494 | |||
| 532c08fe2e | |||
| 704b9674f4 | |||
| 3de94280d2 | |||
| 65897789f6 | |||
| 5d097c3a95 | |||
| 4023e752a0 | |||
| 9a722b1a24 | |||
| 481b4b03dc | |||
| b7fd2f7902 | |||
| f2e1e59d6a | |||
| 3af2ecf1f4 | |||
| 1b2f2c891c | |||
| 155f3259f2 | |||
| f52d8d68b8 | |||
| 216d6e152c | |||
| b6f90e727c | |||
| 790bbc544f | |||
| bd511f7dc6 | |||
| e91c8c28a8 | |||
| 3c6d1afa97 | |||
| 3947e109b4 | |||
| 37b4727a29 | |||
| 2604d0002a | |||
| cca337ab31 | |||
| bb6e766a09 | |||
| af203ae51f | |||
| 01cbdde70e | |||
| e70ed311ed | |||
| c732cddf06 | |||
| 1f71f957e2 | |||
| 757c5fab19 | |||
| cfa537db1f | |||
| bf87662f99 | |||
| 4273edd836 | |||
| 7ce41fc1c1 | |||
| 6e7c766945 | |||
| 55b457a4c0 | |||
| fb7a576e00 | |||
| 30a559b279 | |||
| f77d5fdf14 | |||
| 0a0667889c | |||
| 14d8cd54d7 | |||
| 5fa3d405e6 | |||
| 34eb335fd0 | |||
| c910530927 | |||
| 69e1a6cf6b | |||
| bd84613624 | |||
| 0b4777fc6b | |||
| e22813caec | |||
| 8f6e8432de | |||
| b3c98cecc3 | |||
| 49a18a977b | |||
| a5d0feeedf | |||
| a574e73b44 | |||
| a66f6a739f | |||
| cc7e1b54b6 | |||
| 28cb7fcd3d | |||
| aeb370beca | |||
| 239707e2da | |||
| c1e2778735 | |||
| fb608a554d | |||
| 7561065802 | |||
| 56c8d89999 | |||
| 9192760f3c | |||
| 423695c24d | |||
| 40ec24db69 | |||
| ba8d0a3438 | |||
| 82decf99a6 | |||
| 6ba9fc1fec | |||
| 715d94c2ed | |||
| e1a722f479 | |||
| edbe12c512 | |||
| 9fc6542792 | |||
| 4c01ee26c2 | |||
| 813b9fcf61 | |||
| fe070e0177 | |||
| 423bb87ed8 | |||
| 1641f51b0c | |||
| 3f78a1f3d1 |
@@ -1,4 +1,3 @@
|
|||||||
github: zarzet
|
github: zarzet
|
||||||
ko_fi: zarzet
|
ko_fi: zarzet
|
||||||
buy_me_a_coffee: zarzet
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2 # Need previous commit to compare
|
fetch-depth: 2 # Need previous commit to compare
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -60,23 +60,23 @@ jobs:
|
|||||||
df -h
|
df -h
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.25"
|
go-version: "1.26"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
- name: Cache Gradle
|
- name: Cache Gradle
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -158,7 +158,7 @@ jobs:
|
|||||||
ls -la
|
ls -la
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: android-apk
|
name: android-apk
|
||||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||||
@@ -169,17 +169,17 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.25"
|
go-version: "1.26"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
- name: Cache CocoaPods
|
- name: Cache CocoaPods
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ios/Pods
|
path: ios/Pods
|
||||||
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||||
@@ -295,7 +295,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: build/ios/ipa/SpotiFLAC-*.ipa
|
path: build/ios/ipa/SpotiFLAC-*.ipa
|
||||||
@@ -308,7 +308,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Extract changelog for version
|
- name: Extract changelog for version
|
||||||
id: changelog
|
id: changelog
|
||||||
@@ -338,13 +338,13 @@ jobs:
|
|||||||
cat /tmp/changelog.txt
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: android-apk
|
name: android-apk
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
- name: Download iOS IPA
|
- name: Download iOS IPA
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
path: ./release
|
||||||
@@ -385,7 +385,7 @@ jobs:
|
|||||||
cat /tmp/release_body.txt
|
cat /tmp/release_body.txt
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.get-version.outputs.version }}
|
tag_name: ${{ needs.get-version.outputs.version }}
|
||||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||||
@@ -403,16 +403,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: android-apk
|
name: android-apk
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
- name: Download iOS IPA
|
- name: Download iOS IPA
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|||||||
@@ -1,5 +1,436 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- **Audio Format Conversion**: Convert between FLAC, MP3, and Opus directly from Track Metadata screen with full metadata and cover art preservation
|
||||||
|
- **PC v7.0.8 Backend Merge**: Adopts several Go backend improvements from SpotiFLAC PC v7.0.8 including Amazon encrypted stream support, SpotFetch metadata fallback, and Qobuz API update
|
||||||
|
- **Amazon Music Re-enabled**: Amazon provider back in service with new API
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- "Use Primary Artist Only" setting: strips featured artists from folder names (e.g. "Justin Bieber, Quavo" becomes "Justin Bieber") for cleaner folder organization
|
||||||
|
- Supports separators: `, ` `;` `&` `feat.` `ft.` `featuring` `with` `x`
|
||||||
|
- Available in Settings > Download > below "Use Album Artist for folders"
|
||||||
|
- Audio format conversion from Track Metadata screen
|
||||||
|
- Convert between FLAC, MP3, and Opus formats (any direction)
|
||||||
|
- Selectable bitrate: 128k, 192k, 256k, 320k
|
||||||
|
- Full metadata and cover art preservation during conversion
|
||||||
|
- Confirmation dialog before converting (original file deleted after)
|
||||||
|
- SAF storage support: copies to temp, converts, writes back via SAF
|
||||||
|
- Download history automatically updated with new file path
|
||||||
|
- Unified download request contract (`DownloadRequestPayload`) for all providers/flows
|
||||||
|
- Includes full superset fields: lyrics mode, genre/label/copyright, provider IDs, SAF params, cover/quality settings
|
||||||
|
- Added strategy flags in payload: `use_extensions`, `use_fallback`
|
||||||
|
- New Go unified router entrypoint: `DownloadByStrategy(requestJSON)`
|
||||||
|
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||||
|
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||||
|
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||||
|
- New backend client for `spotify.afkarxyz.fun/api`
|
||||||
|
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||||
|
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||||
|
- Includes heuristic detection of lyrics stored in Comment fields
|
||||||
|
- Edit Metadata now supports manual cover selection (pick/replace cover image) and embeds it into audio tags on save
|
||||||
|
- Save Lyrics now shows an immediate in-progress snackbar (`Saving lyrics...`) so users know the operation has started
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Merged several Go backend improvements from SpotiFLAC PC v7.0.8: Amazon new API with encrypted stream/decryption support, SpotFetch metadata fallback for Spotify-blocked regions, multi-format lyrics extraction (MP3/Opus/OGG), Qobuz Jumo API update.
|
||||||
|
- Download queue execution now builds one payload and uses a single bridge entrypoint (`PlatformBridge.downloadByStrategy`) instead of branching into multiple bridge methods
|
||||||
|
- Dart `downloadByStrategy` now sends a single request to Go (`downloadByStrategy` channel); routing concern is centralized in Go backend
|
||||||
|
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||||
|
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||||
|
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||||
|
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||||
|
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||||
|
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||||
|
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||||
|
- Edit Metadata cover section moved to the top of the form and now previews current embedded cover before replacement (plus selected replacement preview)
|
||||||
|
- Edit Metadata cover preview enlarged (120px to 160px) with shadow, side-by-side layout for current vs selected cover, and label repositioned below image
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed lyrics mode "External .LRC" still embedding lyrics into metadata - `lyrics_mode` was not being sent to Go backend for single-service downloads and YouTube provider, causing Go to default to "embed"
|
||||||
|
- Fixed `flutter_local_notifications` v20 breaking changes - migrated all `initialize()`, `show()`, and `cancel()` calls from positional parameters to named parameters
|
||||||
|
- Fixed SAF duplicate folder bug: concurrent batch downloads creating empty folders with `(1)`, `(2)`, `(3)` suffixes - added synchronized lock to `ensureDocumentDir` in Kotlin with duplicate detection and cleanup
|
||||||
|
- Track Metadata lyrics section now hides "Embed Lyrics" when lyrics are already embedded in file, preventing redundant embed attempts
|
||||||
|
- Fixed lyrics embed path to support FLAC/MP3/Opus consistently (including SAF files) without forcing unsupported parser paths
|
||||||
|
- Inconsistent parameter parity across download paths
|
||||||
|
- `downloadWithExtensions` now carries `copyright`
|
||||||
|
- YouTube path now carries `embed_max_quality_cover` and metadata parity fields
|
||||||
|
- Inconsistent success response metadata between direct/fallback flows
|
||||||
|
- Added shared Go response builder for `DownloadTrack` and `DownloadWithFallback`
|
||||||
|
- Success responses now consistently include `genre`, `label`, `copyright`, and `lyrics_lrc`
|
||||||
|
- YouTube success response now also includes extended metadata fields (`cover_url`, `genre`, `label`, `copyright`) for parity with other providers
|
||||||
|
- Fixed `Save Lyrics` crash on Android (`java.lang.Integer cannot be cast to java.lang.Long`) by normalizing `duration_ms` channel argument as `Number -> Long`
|
||||||
|
- Fixed FLAC Re-enrich cover edge case where metadata could be written without cover when temp cover file creation failed; FLAC cover embed now uses in-memory bytes and verifies cover after write
|
||||||
|
- Fixed FLAC picture-block embed robustness by detecting image MIME via magic bytes (JPEG/PNG/GIF/WEBP) instead of relying on filename extension
|
||||||
|
- Fixed MP3/Opus metadata rewrite flows to preserve existing embedded cover when no new cover is available
|
||||||
|
- Fixed Library tab cover not updating after manual cover edit/re-embed for downloaded tracks
|
||||||
|
- Queue/Library now prefers embedded cover art extracted from local files (not just cached `coverUrl`)
|
||||||
|
- Added per-track extraction cache with file-modification invalidation so updated embedded art is reflected in Library
|
||||||
|
- Extraction is now on-demand for edited tracks only (not full-library reload)
|
||||||
|
- Returning from Track Metadata now refreshes cover cache only for the affected track
|
||||||
|
- Cover refresh is now skipped when file modification time is unchanged, removing unnecessary flash when simply opening/closing metadata screen
|
||||||
|
- Fixed repeated cover preview extraction in Track Metadata screen (`track_cover_preview_*`) causing visible flash when reopening
|
||||||
|
- Added in-memory preview cache keyed by file path so reopening metadata reuses existing preview without re-extract
|
||||||
|
- Cache validation uses file modification time for filesystem paths; SAF paths are refreshed only after successful edit actions
|
||||||
|
- Queue/Library now also compares SAF file last-modified (`getSafFileModTimes`) before refreshing embedded-cover cache
|
||||||
|
- Preview cache key is now stable per track item (not volatile temp SAF path), eliminating false cache misses on SAF-backed files
|
||||||
|
- Track Metadata no longer auto-extracts cover preview on every screen open; extraction now runs only after actual edit/re-enrich changes (or when explicitly forced)
|
||||||
|
- Track metadata edits/re-enrich now sync updated tags back into `downloadHistoryProvider` + SQLite history rows
|
||||||
|
- Non-Library screens that read download history (Home/album/history views) now reflect updated title/artist/album/tags without manual rescan
|
||||||
|
- Track Metadata back-navigation now returns an explicit update result after successful edits/re-enrich, enabling History-tab cover refresh fallback when SAF timestamps are unreliable
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Configured Flutter image cache limits (240 entries / 60 MiB) and added `ResizeImage` wrappers for cover art precaching across all screens, reducing peak memory usage on cover-heavy pages
|
||||||
|
- Added LRU eviction to Deezer cache with configurable max entries per cache type (search/album/artist/ISRC) and periodic expired-entry cleanup to prevent unbounded memory growth in long sessions
|
||||||
|
- Download progress notifications are now normalized (2-decimal progress, 1-decimal speed, 0.1 MiB byte steps) and deduplicated by track/artist/percent/queue-count, reducing notification overhead during batch downloads
|
||||||
|
- Each queue item now uses a dedicated `ConsumerWidget` with per-item `.select()` instead of rebuilding the entire list on any item change; items are wrapped in `RepaintBoundary` for paint isolation
|
||||||
|
- Queue/Library search indexes are now built on-demand per item instead of upfront for all items, with bounded LRU caches (max 4000 entries)
|
||||||
|
- `copyWith` now preserves derived lookup indexes (ISRC map, track key set) when items list is unchanged, avoiding O(n) rebuild on every scan progress update
|
||||||
|
- Scan progress polling now compares values before calling `setState`, skipping unnecessary widget rebuilds when nothing changed
|
||||||
|
- Added in-flight flag to download progress and library scan polling to prevent concurrent timer callbacks from overlapping
|
||||||
|
- New `DownloadedEmbeddedCoverResolver` service replaces per-screen cover extraction logic with a shared bounded cache (160 entries), mod-time validation, and throttled refresh checks
|
||||||
|
- Multiple embedded cover change callbacks are now coalesced into a single frame via `addPostFrameCallback`, preventing redundant rebuilds
|
||||||
|
- Downloaded album screen now caches filtered/sorted track lists and reuses them when the source data reference is unchanged
|
||||||
|
- Home tab recent downloads now use single-pass aggregation instead of building full per-album lists, and store only IDs instead of full item objects for the clear-all action
|
||||||
|
- Removed duplicate `_downloadedSpotifyIds` Set and `_isrcSet` (both now use existing map lookups), removed unused `_isTyping` state in home tab
|
||||||
|
- Track cache pre-warming is now capped at 80 tracks per request to avoid excessive backend calls on large playlists
|
||||||
|
- About page contributor avatars now use `memCacheWidth`/`memCacheHeight` to decode at display size instead of full resolution
|
||||||
|
- Orphaned download cleanup now checks file existence in parallel (chunk 16) instead of sequentially
|
||||||
|
- Local library `findByTrackAndArtist` now uses O(1) map lookup (`_byTrackKey`) instead of O(n) linear scan
|
||||||
|
- Local library database load and SharedPreferences fetch now run in parallel
|
||||||
|
- Legacy mod-time backfill now uses chunked parallel `File.stat` (chunk 24) with per-chunk cancel check
|
||||||
|
- Downloaded album screen now caches disc grouping, sorted disc numbers, common quality, and embedded cover path with reference-identity invalidation
|
||||||
|
- Local album screen common quality is now computed once during cache rebuild instead of per-build
|
||||||
|
- Batch delete in album screens now uses O(1) map lookup (`tracksById`) instead of `.where().firstOrNull`
|
||||||
|
- Cache management page now fires all async init calls in parallel and uses chunked async directory deletion (chunk 24)
|
||||||
|
- Cover resolver preview file existence check is now throttled (2.2s interval) to reduce synchronous I/O in build path
|
||||||
|
- History and library database DELETE operations are now chunked (500 per batch) to stay within SQLite variable limits
|
||||||
|
- Library database `cleanupMissingFiles` now checks file existence in parallel (chunk 16) and deletes in batched SQL
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- All logs (Go and Dart) now automatically redact Bearer tokens, access/refresh tokens, client secrets, API keys, and passwords using regex-based sanitization before storage
|
||||||
|
- Extension auth URLs are now validated for HTTPS-only, no embedded credentials, and no private/local network targets before opening
|
||||||
|
- Auth URLs in logs are summarized to scheme+host+path only (query params stripped) to prevent token leakage; token exchange error bodies are truncated and sanitized
|
||||||
|
- Extension HTTP requests now block URLs with embedded credentials (`user:pass@host`)
|
||||||
|
- Extension storage files changed from `0644` to `0600` (owner-only read/write)
|
||||||
|
- All SAF relative directory paths are now sanitized per-segment with `.`/`..` filtering; all user-provided file names pass through `sanitizeFilename()` before use
|
||||||
|
- Extension ID is sanitized before building download destination path
|
||||||
|
- Log export device info now shows Build ID and Security Patch level instead of masked Device ID
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Centralized request serialization in `PlatformBridge` via shared invoke helper and unified payload model
|
||||||
|
- Go strategy router normalizes incoming service casing before dispatch
|
||||||
|
- Extension runtime: `customSearch` now passes query/options via VM globals instead of string interpolation, preventing parser edge cases on certain devices
|
||||||
|
- Extension runtime: JS panic handler now logs full stack trace for easier debugging
|
||||||
|
- `DownloadQueueLookup` expanded with `byItemId` map and `itemIds` list for O(1) queue item access from UI
|
||||||
|
- Non-error/non-fatal log entries are now skipped entirely (not just hidden) when detailed logging is disabled, reducing buffer growth and Go log polling overhead
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Buy Me a Coffee references removed from donate page, FUNDING.yml, README, and all localization files (account suspended)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.6.0] - 2026-02-09
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- **YouTube Provider (Lossy)**: New download option via Cobalt API for tracks not available on lossless services
|
||||||
|
- Opus 256kbps (recommended) or MP3 320kbps quality options
|
||||||
|
- Full metadata embedding: cover art, title, artist, album, track/disc number, year, ISRC
|
||||||
|
- Lyrics fetching from lrclib.net with embed and external .lrc support
|
||||||
|
- Works as fallback when Tidal/Qobuz/Amazon downloads fail
|
||||||
|
- **Edit Metadata**: Edit embedded metadata directly from the Track Metadata screen (FLAC, MP3, Opus)
|
||||||
|
- Editable fields: Title, Artist, Album, Album Artist, Date, Track#, Disc#, Genre, ISRC
|
||||||
|
- Advanced fields: Label, Copyright, Composer, Comment
|
||||||
|
- FLAC: native Go writer, MP3/Opus: FFmpeg-based writer
|
||||||
|
- UI refreshes in-place after save without needing to re-open the screen
|
||||||
|
- iOS and Android support
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Save Cover Art: download high-quality album art as standalone .jpg from track metadata screen
|
||||||
|
- Save Lyrics (.lrc): fetch and save lyrics as standalone .lrc file without downloading the song
|
||||||
|
- Re-enrich Metadata: re-embed metadata, cover art, and lyrics into existing audio files without re-downloading (FLAC native, MP3/Opus via FFmpeg)
|
||||||
|
- Re-enrich now supports local library items: searches Spotify/Deezer by track name + artist to fetch complete metadata from the internet, then embeds cover art, lyrics, genre, label, and all tags into the file
|
||||||
|
- YouTube download provider using Cobalt API with SongLink/Odesli integration for Spotify/Deezer ID → YouTube URL conversion
|
||||||
|
- SpotubeDL as fallback Cobalt proxy when primary API fails
|
||||||
|
- YouTube video ID detection for YT Music extension compatibility
|
||||||
|
- Parallel cover art and lyrics fetching during YouTube download
|
||||||
|
- Queue progress now shows "X.X MB" instead of "0%" for streaming downloads where total size is unknown (Cobalt tunnel mode)
|
||||||
|
- Full metadata pipeline for YouTube downloads: cover art, lyrics, title, artist, album, track#, disc#, year, ISRC
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed Tidal HIGH (lossy AAC) quality option - use YouTube provider for lossy downloads instead
|
||||||
|
- Simplified download service picker by removing dead lossy format code
|
||||||
|
- Removed Amazon from download settings UI (now only used as automatic fallback)
|
||||||
|
- Cleaned up dead disabled-chip code in download service selector
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed `error.api.youtube.login` by using YouTube Music URLs instead of regular YouTube URLs for Cobalt requests
|
||||||
|
- Fixed SongLink to prioritize `youtubeMusic` platform URL over `youtube` for Cobalt compatibility
|
||||||
|
- Fixed YouTube metadata not being overwritten by setting `DisableMetadata: true` in Cobalt requests
|
||||||
|
- Fixed ISRC validation in metadata enrichment flow - invalid ISRCs no longer trigger failed Deezer lookups
|
||||||
|
- Fixed YouTube metadata enrichment to work like other providers (SongLink Deezer ID extraction, proper metadata embedding)
|
||||||
|
- Go metadata parsers now read Composer, Comment, Label, Copyright from FLAC, MP3 (ID3v2.2/v2.3/v2.4), and Opus/OGG files
|
||||||
|
- Added proper COMM frame parser for ID3v2 (handles language code + description prefix correctly)
|
||||||
|
- Fixed Re-enrich Metadata failing on SAF storage files (`content://` URIs) - Kotlin now copies SAF file to temp, Go processes temp file, then writes back for FLAC or returns temp path for FFmpeg (MP3/Opus)
|
||||||
|
- Fixed Save Cover Art and Save Lyrics crashing on SAF-stored download history items - now saves to temp then writes to SAF tree via `createSafFileFromPath`
|
||||||
|
- Fixed `_getFileDirectory()` crash when called with `content://` URI by adding SAF guard
|
||||||
|
- Fixed `readAudioMetadata` Kotlin handler not handling SAF URIs - now copies to temp for reading
|
||||||
|
- Added metadata summary log in Re-enrich flow showing all fields before embedding (title, artist, album, track#, disc#, date, ISRC, genre, label)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.5.3] - 2026-02-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- CSV import flow now includes a new option: **Skip already downloaded songs** before enqueueing tracks
|
||||||
|
- Added regression test suite for cross-script matching behavior in Go backend (`go_backend/matching_test.go`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- CSV import confirmation dialog now supports filtering out tracks already present in download history (matched by Spotify ID and ISRC)
|
||||||
|
- CSV import enqueue feedback now reports added/skipped counts when duplicate downloads are skipped
|
||||||
|
- Home search now prioritizes **Recent Access** when search field is focused with empty input, even if old search results still exist in memory
|
||||||
|
- Search filter/result sections are now hidden while Recent Access mode is active to avoid stale-result overlap
|
||||||
|
- Recent Access now shows a localized empty-state message when no recent items are available
|
||||||
|
- Normalized collapsing AppBar top inset across iOS/Android so header height/animation stays visually consistent on Apple devices
|
||||||
|
- Storage & Cache UX improved: `Clear all cache` now preserves web/runtime cache by default (optional), with explicit warnings/actions for runtime cache resets
|
||||||
|
- Local library settings now include a display count for tracks excluded because they already exist in download history
|
||||||
|
- Responsive layout tuning applied across key screens to reduce hardcoded-height overflow issues on smaller devices
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed false-positive cross-script matching in Qobuz/Tidal where unrelated titles/artists in different scripts could be incorrectly accepted
|
||||||
|
- Cross-script title/artist matching now requires transliteration-aware normalization and strict similarity checks instead of auto-accepting script differences
|
||||||
|
- Qobuz metadata fallback no longer scans all results when zero title matches are found; title verification is now required
|
||||||
|
- Qobuz metadata final validation now rejects results when title does not match expected track name
|
||||||
|
- Fixed Home search regression where Recent Access panel could disappear after previous searches
|
||||||
|
- Fixed Local Library card/layout crash caused by `Flex` usage under unbounded height constraints
|
||||||
|
- Hardened FFmpeg metadata embedding temp-file naming to prevent rare collisions during parallel downloads/fallback flows (Qobuz → Tidal) that could cause missing embedded metadata
|
||||||
|
- Fixed SAF external lyrics naming where some providers saved `.lrc` files as `.lrc.txt`; LRC export now uses neutral MIME to preserve `.lrc` extension
|
||||||
|
|
||||||
|
## [3.5.2] - 2026-02-08
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Home tab search result sections are now virtualized with `SliverList` (lazy item build) instead of eager `Column` rendering, reducing frame drops on large result sets
|
||||||
|
- Home tab now narrows Riverpod subscriptions using field-level `select(...)` for search/provider state to reduce unnecessary full-tab rebuilds
|
||||||
|
- Search provider dropdown now watches only required fields (`searchProvider`, `metadataSource`, `extensions`) instead of full provider states
|
||||||
|
- Track row rendering in Home search now receives precomputed thumbnail sizing/local-library flags from parent to avoid repeated per-item provider watches
|
||||||
|
- Removed thumbnail `debugPrint` calls inside track row `build()` to reduce runtime overhead during scrolling/rebuilds
|
||||||
|
- Queue tab root subscription no longer watches full queue item list; it now watches only queue presence (`items.isNotEmpty`) to avoid full Library UI rebuilds on every progress tick
|
||||||
|
- Queue download header/list rendering has been isolated into dedicated `Consumer` slivers; header now watches only queue length (`items.length`) while item list watches queue item updates
|
||||||
|
- Queue filter/sort computations are now centralized and memoized per filter mode within a build pass (`all`/`albums`/`singles`), reducing repeated list transforms for chip counts and page content
|
||||||
|
- Selection bottom bar content is now computed only when selection mode is active, removing hidden-state heavy list preparation
|
||||||
|
- File existence checks in queue/library rows now use per-path `ValueNotifier` + `ValueListenableBuilder` updates instead of triggering global `setState`, reducing unnecessary whole-tab repaints
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Replaced date range filter with sorting options in Library tab: Latest, Oldest, A-Z, Z-A
|
||||||
|
- Sorting applies to all views: unified items, downloaded albums, and local library albums
|
||||||
|
- Local library items now use file modification time (`fileModTime`) for sorting instead of scan time, providing more accurate chronological ordering
|
||||||
|
- Removed redundant manual "Export Failed Downloads" button from Library UI (auto-export setting in Settings is sufficient)
|
||||||
|
- Library filters (quality, format, source) now correctly apply to album tabs and update tab chip counts (All/Albums/Singles)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed local library scan crashing on Samsung One UI devices due to MediaStore URI mismatch in SAF tree traversal
|
||||||
|
- Added MediaStore URI fallback in SAF file reader: when SAF permission is denied for Samsung-returned MediaStore URIs, automatically retries using READ_MEDIA_AUDIO permission
|
||||||
|
- Hardened SAF scan with per-directory and per-file error handling: scan now skips problematic files instead of aborting entirely
|
||||||
|
- Added visited directory tracking to prevent infinite loops from circular SAF references
|
||||||
|
- Fixed metadata enrichment cascading failure after one queued download fails: metadata APIs (Deezer, SongLink, Spotify) now use isolated `metadataTransport` so failed download connections cannot poison metadata requests
|
||||||
|
- Added immediate connection cleanup on every download failure path (error response and exception), not only periodic cleanup every N downloads
|
||||||
|
- Fixed incremental SAF scan edge case where `lastModified()` failure could misclassify existing files as removed (`removedUris`)
|
||||||
|
- Fixed tracks marked "In Library" still showing active download button - download button now shows as completed (checkmark) for local library tracks across all screens (album, playlist, artist, home/search)
|
||||||
|
- Fixed FFmpeg M4A-to-FLAC conversion erroneously triggered on already-existing FLAC files when re-downloading duplicates via Tidal
|
||||||
|
- Fixed SAF download creating empty artist/album folders when re-downloading duplicate tracks; directory is now only created after confirming the file does not already exist
|
||||||
|
|
||||||
|
## [3.5.1] - 2026-02-08
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Removed PaletteService (palette_generator) from all screens for faster navigation and reduced memory usage
|
||||||
|
- Album, Playlist, Downloaded Album, Local Album, and Track Metadata screens now use blurred cover art as header background instead of dominant color extraction
|
||||||
|
- Removed `palette_generator` dependency
|
||||||
|
- App startup now renders immediately (`runApp`) while service initialization runs asynchronously in eager init
|
||||||
|
- Main shell provider subscriptions now use field-level `select(...)` to reduce unnecessary rebuilds
|
||||||
|
- Settings persistence now uses single-flight + queued save coalescing to avoid redundant disk writes
|
||||||
|
- Progress polling cadence adjusted to 800ms for download queue, local library scan progress, and Go log polling
|
||||||
|
- Android foreground download service progress updates are throttled (change-based updates + 5s heartbeat)
|
||||||
|
- SAF history repair is now batched (`20` items per batch) and capped per launch (`60`) to reduce startup I/O spikes
|
||||||
|
- Incremental library scan now builds final item list in-memory instead of reloading from database
|
||||||
|
- Local cover images in queue/library use direct `Image.file` with `errorBuilder` instead of `FutureBuilder` existence check
|
||||||
|
- CSV parser `_parseLine` rewritten: correct escaped-quote handling, no quote characters in output
|
||||||
|
- Removed unused legacy screen files (`home_screen.dart`, `queue_screen.dart`, `settings_screen.dart`, `settings_tab.dart`)
|
||||||
|
- Incremental local library scan now merges delta results in-memory and sorts once, avoiding full-state reload churn
|
||||||
|
- Queue local cover rendering now uses direct `Image.file` + `errorBuilder` (removed repeated async file-exists checks)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Auto-cleanup orphaned downloads on history load (files that no longer exist are automatically removed from history)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed legacy screen files that were no longer used after the tab/part refactor:
|
||||||
|
- `lib/screens/home_screen.dart`
|
||||||
|
- `lib/screens/queue_screen.dart`
|
||||||
|
- `lib/screens/settings_screen.dart`
|
||||||
|
- `lib/screens/settings_tab.dart`
|
||||||
|
- Concurrent download limit increased from `3` to `5` (settings clamp + Options UI chips now support `1..5`)
|
||||||
|
- Download queue now uses a single parallel scheduler path; `1` concurrency is handled as parallel-with-limit-1 (no separate sequential engine)
|
||||||
|
- Download queue now listens to settings updates in real-time so concurrency/output settings stay in sync while queue is active
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- CSV parser now correctly handles escaped quotes (`""`) inside quoted fields during import
|
||||||
|
- Fixed dynamic concurrency update during active downloads: changing limit (e.g. `1 -> 3`) now schedules additional queued items without waiting current active item to finish
|
||||||
|
- Queue scheduler now re-checks capacity/queued items on short intervals to avoid blocking on long-running single active download
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
#### Flutter
|
||||||
|
- `flutter_local_notifications` 19.x → 20.0.0 (breaking: all positional params converted to named params)
|
||||||
|
- `connectivity_plus` 6.x → 7.0.0
|
||||||
|
- `flutter_secure_storage` 9.x → 10.0.0
|
||||||
|
- Removed `palette_generator` dependency
|
||||||
|
|
||||||
|
#### Go
|
||||||
|
- `go-flac/go-flac` v1.0.0 → v2.0.4
|
||||||
|
- `go-flac/flacvorbis` v0.2.0 → v2.0.2
|
||||||
|
- `go-flac/flacpicture` v0.3.0 → v2.0.2
|
||||||
|
- Go toolchain 1.24 → 1.25.7
|
||||||
|
|
||||||
|
#### Android
|
||||||
|
- Android Gradle Plugin 8.x → 9.0.0
|
||||||
|
- Kotlin 2.1.x → 2.3.10
|
||||||
|
- `desugar_jdk_libs` → 2.1.5
|
||||||
|
- `kotlinx-coroutines-android` → 1.10.2
|
||||||
|
- `lifecycle-runtime-ktx` → 2.10.0
|
||||||
|
- `activity-ktx` → 1.12.3
|
||||||
|
|
||||||
|
#### CI/CD
|
||||||
|
- `actions/cache` v4 → v5
|
||||||
|
- `actions/checkout` v4 → v6
|
||||||
|
- `actions/setup-go` v5 → v6
|
||||||
|
- `actions/setup-java` v4 → v5
|
||||||
|
- `softprops/action-gh-release` v1 → v2
|
||||||
|
- GitHub artifact actions updated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.5.0] - 2026-02-07
|
## [3.5.0] - 2026-02-07
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/516142f029a4f3642a899832a6f600acf07040170a98c106cd03222cf584d9a3)
|
[](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -24,15 +24,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
|
|||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Search Source
|
|
||||||
|
|
||||||
SpotiFLAC supports multiple search sources for finding music metadata:
|
|
||||||
|
|
||||||
| Source | Setup |
|
|
||||||
|--------|-------|
|
|
||||||
| **Deezer** (Default) | No setup required |
|
|
||||||
| **Extensions** | Install additional search providers from the Store |
|
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||||
@@ -54,15 +45,8 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
|
|||||||
|
|
||||||
## Telegram
|
## Telegram
|
||||||
|
|
||||||
<p align="center">
|
[](https://t.me/spotiflac)
|
||||||
<a href="https://t.me/spotiflac">
|
[](https://t.me/spotiflac_chat)
|
||||||
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://t.me/spotiflac_chat">
|
|
||||||
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
@@ -87,9 +71,9 @@ A: Some countries have restricted access to certain streaming service APIs. If d
|
|||||||
|
|
||||||
### Want to support SpotiFLAC-Mobile?
|
### Want to support SpotiFLAC-Mobile?
|
||||||
|
|
||||||
_If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going._
|
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
||||||
|
|
||||||
[](https://ko-fi.com/zarzet) <a href="https://www.buymeacoffee.com/zarzet" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 40px !important;width: 150px !important;" ></a>
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
@@ -108,6 +92,16 @@ You are solely responsible for:
|
|||||||
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
||||||
|
|
||||||
|
|
||||||
|
## API Credits
|
||||||
|
|
||||||
|
- **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), [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)
|
||||||
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||||
|
|||||||
@@ -96,13 +96,13 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
|
|
||||||
// Include all AAR and JAR files from libs folder
|
// Include all AAR and JAR files from libs folder
|
||||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||||
implementation("androidx.documentfile:documentfile:1.0.1")
|
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||||
implementation("androidx.activity:activity-ktx:1.9.0")
|
implementation("androidx.activity:activity-ktx:1.12.3")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||||
private val safScanLock = Any()
|
private val safScanLock = Any()
|
||||||
|
private val safDirLock = Any()
|
||||||
private var safScanProgress = SafScanProgress()
|
private var safScanProgress = SafScanProgress()
|
||||||
@Volatile private var safScanCancel = false
|
@Volatile private var safScanCancel = false
|
||||||
@Volatile private var safScanActive = false
|
@Volatile private var safScanActive = false
|
||||||
@@ -299,27 +300,55 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
|
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||||
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
if (relativeDir.isBlank()) return ""
|
||||||
if (relativeDir.isBlank()) return current
|
return relativeDir
|
||||||
|
.split("/")
|
||||||
|
.map { sanitizeFilename(it) }
|
||||||
|
.filter { it.isNotBlank() && it != "." && it != ".." }
|
||||||
|
.joinToString("/")
|
||||||
|
}
|
||||||
|
|
||||||
val parts = relativeDir.split("/").filter { it.isNotBlank() }
|
private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
||||||
for (part in parts) {
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
val existing = current.findFile(part)
|
if (safeRelativeDir.isBlank()) {
|
||||||
current = if (existing != null && existing.isDirectory) {
|
return DocumentFile.fromTreeUri(this, treeUri)
|
||||||
existing
|
}
|
||||||
} else {
|
|
||||||
current.createDirectory(part) ?: return null
|
// Synchronize to prevent concurrent downloads from creating duplicate
|
||||||
}
|
// directories with (1), (2) suffixes via SAF's auto-rename behavior.
|
||||||
|
synchronized(safDirLock) {
|
||||||
|
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
||||||
|
|
||||||
|
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||||
|
for (part in parts) {
|
||||||
|
val existing = current.findFile(part)
|
||||||
|
current = if (existing != null && existing.isDirectory) {
|
||||||
|
existing
|
||||||
|
} else {
|
||||||
|
val created = current.createDirectory(part) ?: return null
|
||||||
|
// SAF may auto-rename to "part (1)" if another thread just created it.
|
||||||
|
// Re-check: if the created name differs, delete it and use the original.
|
||||||
|
val createdName = created.name ?: part
|
||||||
|
if (createdName != part) {
|
||||||
|
// Another thread won the race; delete the duplicate and use theirs.
|
||||||
|
created.delete()
|
||||||
|
current.findFile(part) ?: return null
|
||||||
|
} else {
|
||||||
|
created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current
|
||||||
}
|
}
|
||||||
return current
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
private fun findDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
|
||||||
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
|
||||||
if (relativeDir.isBlank()) return current
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
|
if (safeRelativeDir.isBlank()) return current
|
||||||
|
|
||||||
val parts = relativeDir.split("/").filter { it.isNotBlank() }
|
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||||
for (part in parts) {
|
for (part in parts) {
|
||||||
val existing = current.findFile(part)
|
val existing = current.findFile(part)
|
||||||
if (existing == null || !existing.isDirectory) return null
|
if (existing == null || !existing.isDirectory) return null
|
||||||
@@ -359,14 +388,21 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
obj.put("relative_dir", "")
|
obj.put("relative_dir", "")
|
||||||
return obj.toString()
|
return obj.toString()
|
||||||
}
|
}
|
||||||
|
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||||
|
val safeFileName = sanitizeFilename(fileName)
|
||||||
|
if (safeFileName.isBlank()) {
|
||||||
|
obj.put("uri", "")
|
||||||
|
obj.put("relative_dir", "")
|
||||||
|
return obj.toString()
|
||||||
|
}
|
||||||
|
|
||||||
val treeUri = Uri.parse(treeUriStr)
|
val treeUri = Uri.parse(treeUriStr)
|
||||||
val targetDir = findDocumentDir(treeUri, relativeDir)
|
val targetDir = findDocumentDir(treeUri, safeRelativeDir)
|
||||||
if (targetDir != null) {
|
if (targetDir != null) {
|
||||||
val direct = targetDir.findFile(fileName)
|
val direct = targetDir.findFile(safeFileName)
|
||||||
if (direct != null && direct.isFile) {
|
if (direct != null && direct.isFile) {
|
||||||
obj.put("uri", direct.uri.toString())
|
obj.put("uri", direct.uri.toString())
|
||||||
obj.put("relative_dir", relativeDir)
|
obj.put("relative_dir", safeRelativeDir)
|
||||||
return obj.toString()
|
return obj.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,7 +428,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val childPath = if (path.isBlank()) childName else "$path/$childName"
|
val childPath = if (path.isBlank()) childName else "$path/$childName"
|
||||||
queue.add(child to childPath)
|
queue.add(child to childPath)
|
||||||
} else if (child.isFile) {
|
} else if (child.isFile) {
|
||||||
if (child.name == fileName) {
|
if (child.name == safeFileName) {
|
||||||
obj.put("uri", child.uri.toString())
|
obj.put("uri", child.uri.toString())
|
||||||
obj.put("relative_dir", path)
|
obj.put("relative_dir", path)
|
||||||
return obj.toString()
|
return obj.toString()
|
||||||
@@ -408,7 +444,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||||
val provided = req.optString("saf_file_name", "")
|
val provided = req.optString("saf_file_name", "")
|
||||||
if (provided.isNotBlank()) return provided
|
if (provided.isNotBlank()) return sanitizeFilename(provided)
|
||||||
|
|
||||||
val trackName = req.optString("track_name", "track")
|
val trackName = req.optString("track_name", "track")
|
||||||
val artistName = req.optString("artist_name", "")
|
val artistName = req.optString("artist_name", "")
|
||||||
@@ -424,36 +460,159 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return obj.toString()
|
return obj.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? {
|
/**
|
||||||
val mime = contentResolver.getType(uri)
|
* Detect whether a content URI belongs to the MediaStore provider.
|
||||||
val nameHint = (
|
* Samsung One UI may return MediaStore URIs from SAF tree traversal,
|
||||||
DocumentFile.fromSingleUri(this, uri)?.name
|
* which require READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission
|
||||||
?: uri.lastPathSegment
|
* instead of SAF tree permission.
|
||||||
?: ""
|
*/
|
||||||
).lowercase(Locale.ROOT)
|
private fun isMediaStoreUri(uri: Uri): Boolean {
|
||||||
val extFromName = when {
|
val authority = uri.authority ?: return false
|
||||||
nameHint.endsWith(".m4a") -> ".m4a"
|
return authority == "media" ||
|
||||||
nameHint.endsWith(".mp3") -> ".mp3"
|
authority.startsWith("media.") ||
|
||||||
nameHint.endsWith(".opus") -> ".opus"
|
authority.contains("media")
|
||||||
nameHint.endsWith(".flac") -> ".flac"
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
|
||||||
|
*/
|
||||||
|
private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
|
||||||
|
// Try DISPLAY_NAME first
|
||||||
|
try {
|
||||||
|
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val name = cursor.getString(0)?.lowercase(Locale.ROOT) ?: ""
|
||||||
|
val ext = extFromFileName(name)
|
||||||
|
if (ext.isNotBlank()) return ext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
|
||||||
|
// Try MIME_TYPE
|
||||||
|
try {
|
||||||
|
val mime = contentResolver.getType(uri)
|
||||||
|
val ext = extFromMimeType(mime)
|
||||||
|
if (ext.isNotBlank()) return ext
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
|
||||||
|
return fallbackExt ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extFromFileName(name: String): String {
|
||||||
|
return when {
|
||||||
|
name.endsWith(".m4a") -> ".m4a"
|
||||||
|
name.endsWith(".mp3") -> ".mp3"
|
||||||
|
name.endsWith(".opus") -> ".opus"
|
||||||
|
name.endsWith(".flac") -> ".flac"
|
||||||
|
name.endsWith(".ogg") -> ".ogg"
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
val extFromMime = when (mime) {
|
}
|
||||||
|
|
||||||
|
private fun extFromMimeType(mime: String?): String {
|
||||||
|
return when (mime) {
|
||||||
"audio/mp4" -> ".m4a"
|
"audio/mp4" -> ".m4a"
|
||||||
"audio/mpeg" -> ".mp3"
|
"audio/mpeg" -> ".mp3"
|
||||||
"audio/ogg" -> ".opus"
|
"audio/ogg" -> ".opus"
|
||||||
"audio/flac" -> ".flac"
|
"audio/flac" -> ".flac"
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "")
|
}
|
||||||
val suffix: String? = if (ext.isNotBlank()) ext else null
|
|
||||||
val tempFile = File.createTempFile("saf_", suffix, cacheDir)
|
private fun copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? {
|
||||||
contentResolver.openInputStream(uri)?.use { input ->
|
var tempFile: File? = null
|
||||||
FileOutputStream(tempFile).use { output ->
|
var success = false
|
||||||
input.copyTo(output)
|
|
||||||
|
try {
|
||||||
|
val mime = try { contentResolver.getType(uri) } catch (_: Exception) { null }
|
||||||
|
val nameHint = (
|
||||||
|
try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
|
||||||
|
?: uri.lastPathSegment
|
||||||
|
?: ""
|
||||||
|
).lowercase(Locale.ROOT)
|
||||||
|
val extFromName = extFromFileName(nameHint)
|
||||||
|
val extFromMime = extFromMimeType(mime)
|
||||||
|
val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "")
|
||||||
|
val suffix: String? = if (ext.isNotBlank()) ext else null
|
||||||
|
tempFile = File.createTempFile("saf_", suffix, cacheDir)
|
||||||
|
|
||||||
|
contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
FileOutputStream(tempFile).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
success = true
|
||||||
|
return tempFile.absolutePath
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// SAF permission denied - try MediaStore fallback for Samsung One UI
|
||||||
|
// which may return MediaStore URIs from SAF tree traversal
|
||||||
|
if (isMediaStoreUri(uri)) {
|
||||||
|
android.util.Log.d(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"SAF denied for MediaStore URI, trying MediaStore fallback: $uri",
|
||||||
|
)
|
||||||
|
val result = copyMediaStoreUriToTemp(uri, fallbackExt)
|
||||||
|
if (result != null) {
|
||||||
|
success = true
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} ?: return null
|
android.util.Log.w(
|
||||||
return tempFile.absolutePath
|
"SpotiFLAC",
|
||||||
|
"SAF read denied for $uri: ${e.message}",
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"Failed copying SAF uri $uri to temp: ${e.message}",
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
if (!success) {
|
||||||
|
try {
|
||||||
|
tempFile?.delete()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback for Samsung One UI: read a MediaStore content URI using
|
||||||
|
* READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission instead of SAF.
|
||||||
|
* This handles the case where SAF tree traversal returns MediaStore URIs
|
||||||
|
* that the SAF document provider cannot access.
|
||||||
|
*/
|
||||||
|
private fun copyMediaStoreUriToTemp(uri: Uri, fallbackExt: String?): String? {
|
||||||
|
var tempFile: File? = null
|
||||||
|
try {
|
||||||
|
val ext = resolveMediaStoreExt(uri, fallbackExt)
|
||||||
|
val suffix: String? = if (ext.isNotBlank()) ext else null
|
||||||
|
tempFile = File.createTempFile("ms_", suffix, cacheDir)
|
||||||
|
|
||||||
|
contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
FileOutputStream(tempFile).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
} ?: run {
|
||||||
|
tempFile.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"MediaStore fallback succeeded for $uri",
|
||||||
|
)
|
||||||
|
return tempFile.absolutePath
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"MediaStore fallback also failed for $uri: ${e.message}",
|
||||||
|
)
|
||||||
|
try { tempFile?.delete() } catch (_: Exception) {}
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean {
|
private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean {
|
||||||
@@ -476,25 +635,33 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val treeUri = Uri.parse(treeUriStr)
|
val treeUri = Uri.parse(treeUriStr)
|
||||||
val relativeDir = req.optString("saf_relative_dir", "")
|
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
|
||||||
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||||
val mimeType = mimeTypeForExt(outputExt)
|
val mimeType = mimeTypeForExt(outputExt)
|
||||||
|
val fileName = buildSafFileName(req, outputExt)
|
||||||
|
|
||||||
|
// Check for existing file WITHOUT creating the directory first.
|
||||||
|
// This prevents empty folders from being created for duplicate downloads.
|
||||||
|
val existingDir = findDocumentDir(treeUri, relativeDir)
|
||||||
|
if (existingDir != null) {
|
||||||
|
val existing = existingDir.findFile(fileName)
|
||||||
|
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||||
|
val obj = JSONObject()
|
||||||
|
obj.put("success", true)
|
||||||
|
obj.put("message", "File already exists")
|
||||||
|
obj.put("file_path", existing.uri.toString())
|
||||||
|
obj.put("file_name", existing.name ?: fileName)
|
||||||
|
obj.put("already_exists", true)
|
||||||
|
return obj.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only create the directory now that we know we need to download
|
||||||
val targetDir = ensureDocumentDir(treeUri, relativeDir)
|
val targetDir = ensureDocumentDir(treeUri, relativeDir)
|
||||||
?: return errorJson("Failed to access SAF directory")
|
?: return errorJson("Failed to access SAF directory")
|
||||||
|
|
||||||
val fileName = buildSafFileName(req, outputExt)
|
val existingFile = targetDir.findFile(fileName)
|
||||||
val existing = targetDir.findFile(fileName)
|
val document = existingFile ?: targetDir.createFile(mimeType, fileName)
|
||||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
|
||||||
val obj = JSONObject()
|
|
||||||
obj.put("success", true)
|
|
||||||
obj.put("message", "File already exists")
|
|
||||||
obj.put("file_path", existing.uri.toString())
|
|
||||||
obj.put("file_name", existing.name ?: fileName)
|
|
||||||
obj.put("already_exists", true)
|
|
||||||
return obj.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
val document = existing ?: targetDir.createFile(mimeType, fileName)
|
|
||||||
?: return errorJson("Failed to create SAF file")
|
?: return errorJson("Failed to create SAF file")
|
||||||
|
|
||||||
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
|
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
|
||||||
@@ -547,9 +714,14 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
resetSafScanProgress()
|
resetSafScanProgress()
|
||||||
safScanCancel = false
|
safScanCancel = false
|
||||||
safScanActive = true
|
safScanActive = true
|
||||||
|
updateSafScanProgress {
|
||||||
|
it.currentFile = "Scanning folders..."
|
||||||
|
}
|
||||||
|
|
||||||
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||||
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
||||||
|
val visitedDirUris = mutableSetOf<String>()
|
||||||
|
var traversalErrors = 0
|
||||||
|
|
||||||
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
||||||
queue.add(root to "")
|
queue.add(root to "")
|
||||||
@@ -561,22 +733,52 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val (dir, path) = queue.removeFirst()
|
val (dir, path) = queue.removeFirst()
|
||||||
for (child in dir.listFiles()) {
|
val dirUri = dir.uri.toString()
|
||||||
|
if (!visitedDirUris.add(dirUri)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val children = try {
|
||||||
|
dir.listFiles()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
traversalErrors++
|
||||||
|
updateSafScanProgress { it.errorCount = traversalErrors }
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"SAF scan: failed listing directory $dirUri: ${e.message}",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (child in children) {
|
||||||
if (safScanCancel) {
|
if (safScanCancel) {
|
||||||
updateSafScanProgress { it.isComplete = true }
|
updateSafScanProgress { it.isComplete = true }
|
||||||
return "[]"
|
return "[]"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (child.isDirectory) {
|
try {
|
||||||
val childName = child.name ?: continue
|
if (child.isDirectory) {
|
||||||
val childPath = if (path.isBlank()) childName else "$path/$childName"
|
val childName = child.name ?: continue
|
||||||
queue.add(child to childPath)
|
val childPath = if (path.isBlank()) childName else "$path/$childName"
|
||||||
} else if (child.isFile) {
|
val childUri = child.uri.toString()
|
||||||
val name = child.name ?: continue
|
if (childUri == dirUri || visitedDirUris.contains(childUri)) {
|
||||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
continue
|
||||||
if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
|
}
|
||||||
audioFiles.add(child to path)
|
queue.add(child to childPath)
|
||||||
|
} else if (child.isFile) {
|
||||||
|
val name = child.name ?: continue
|
||||||
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
|
if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
|
||||||
|
audioFiles.add(child to path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
traversalErrors++
|
||||||
|
updateSafScanProgress { it.errorCount = traversalErrors }
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"SAF scan: skipped child under $dirUri: ${e.message}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,7 +797,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val results = JSONArray()
|
val results = JSONArray()
|
||||||
var scanned = 0
|
var scanned = 0
|
||||||
var errors = 0
|
var errors = traversalErrors
|
||||||
|
|
||||||
for ((doc, _) in audioFiles) {
|
for ((doc, _) in audioFiles) {
|
||||||
if (safScanCancel) {
|
if (safScanCancel) {
|
||||||
@@ -603,14 +805,22 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return "[]"
|
return "[]"
|
||||||
}
|
}
|
||||||
|
|
||||||
val name = doc.name ?: ""
|
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
|
||||||
updateSafScanProgress {
|
updateSafScanProgress {
|
||||||
it.currentFile = name
|
it.currentFile = name
|
||||||
}
|
}
|
||||||
|
|
||||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
||||||
val tempPath = copyUriToTemp(doc.uri, fallbackExt)
|
val tempPath = try {
|
||||||
|
copyUriToTemp(doc.uri, fallbackExt)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"SAF scan: failed to copy ${doc.uri}: ${e.message}",
|
||||||
|
)
|
||||||
|
null
|
||||||
|
}
|
||||||
if (tempPath == null) {
|
if (tempPath == null) {
|
||||||
errors++
|
errors++
|
||||||
} else {
|
} else {
|
||||||
@@ -618,7 +828,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
|
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
|
||||||
if (metadataJson.isNotBlank()) {
|
if (metadataJson.isNotBlank()) {
|
||||||
val obj = JSONObject(metadataJson)
|
val obj = JSONObject(metadataJson)
|
||||||
val lastModified = doc.lastModified()
|
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
||||||
obj.put("filePath", doc.uri.toString())
|
obj.put("filePath", doc.uri.toString())
|
||||||
obj.put("fileModTime", lastModified)
|
obj.put("fileModTime", lastModified)
|
||||||
results.put(obj)
|
results.put(obj)
|
||||||
@@ -691,10 +901,15 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
resetSafScanProgress()
|
resetSafScanProgress()
|
||||||
safScanCancel = false
|
safScanCancel = false
|
||||||
safScanActive = true
|
safScanActive = true
|
||||||
|
updateSafScanProgress {
|
||||||
|
it.currentFile = "Scanning folders..."
|
||||||
|
}
|
||||||
|
|
||||||
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||||
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
|
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
|
||||||
val currentUris = mutableSetOf<String>()
|
val currentUris = mutableSetOf<String>()
|
||||||
|
val visitedDirUris = mutableSetOf<String>()
|
||||||
|
var traversalErrors = 0
|
||||||
|
|
||||||
// Collect all audio files with lastModified
|
// Collect all audio files with lastModified
|
||||||
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
||||||
@@ -713,7 +928,24 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val (dir, path) = queue.removeFirst()
|
val (dir, path) = queue.removeFirst()
|
||||||
for (child in dir.listFiles()) {
|
val dirUri = dir.uri.toString()
|
||||||
|
if (!visitedDirUris.add(dirUri)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val children = try {
|
||||||
|
dir.listFiles()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
traversalErrors++
|
||||||
|
updateSafScanProgress { it.errorCount = traversalErrors }
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"SAF incremental scan: failed listing directory $dirUri: ${e.message}",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (child in children) {
|
||||||
if (safScanCancel) {
|
if (safScanCancel) {
|
||||||
updateSafScanProgress { it.isComplete = true }
|
updateSafScanProgress { it.isComplete = true }
|
||||||
val result = JSONObject()
|
val result = JSONObject()
|
||||||
@@ -725,24 +957,44 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return result.toString()
|
return result.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (child.isDirectory) {
|
try {
|
||||||
val childName = child.name ?: continue
|
if (child.isDirectory) {
|
||||||
val childPath = if (path.isBlank()) childName else "$path/$childName"
|
val childName = child.name ?: continue
|
||||||
queue.add(child to childPath)
|
val childPath = if (path.isBlank()) childName else "$path/$childName"
|
||||||
} else if (child.isFile) {
|
val childUri = child.uri.toString()
|
||||||
val name = child.name ?: continue
|
if (childUri == dirUri || visitedDirUris.contains(childUri)) {
|
||||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
continue
|
||||||
if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
|
}
|
||||||
|
queue.add(child to childPath)
|
||||||
|
} else if (child.isFile) {
|
||||||
|
// Mark file as present first so it cannot be mis-classified as removed
|
||||||
|
// when provider-specific metadata calls (e.g., lastModified) fail.
|
||||||
val uriStr = child.uri.toString()
|
val uriStr = child.uri.toString()
|
||||||
val lastModified = child.lastModified()
|
|
||||||
currentUris.add(uriStr)
|
currentUris.add(uriStr)
|
||||||
|
|
||||||
// Check if file is new or modified
|
val name = child.name ?: continue
|
||||||
val existingModified = existingFiles[uriStr]
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
if (existingModified == null || existingModified != lastModified) {
|
if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
|
||||||
audioFiles.add(Triple(child, path, lastModified))
|
val existingModified = existingFiles[uriStr]
|
||||||
|
val lastModified = try {
|
||||||
|
child.lastModified()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
existingModified ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file is new or modified
|
||||||
|
if (existingModified == null || existingModified != lastModified) {
|
||||||
|
audioFiles.add(Triple(child, path, lastModified))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
traversalErrors++
|
||||||
|
updateSafScanProgress { it.errorCount = traversalErrors }
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"SAF incremental scan: skipped child under $dirUri: ${e.message}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -772,7 +1024,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
|
|
||||||
val results = JSONArray()
|
val results = JSONArray()
|
||||||
var scanned = 0
|
var scanned = 0
|
||||||
var errors = 0
|
var errors = traversalErrors
|
||||||
|
|
||||||
for ((doc, _, lastModified) in audioFiles) {
|
for ((doc, _, lastModified) in audioFiles) {
|
||||||
if (safScanCancel) {
|
if (safScanCancel) {
|
||||||
@@ -786,14 +1038,22 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
return result.toString()
|
return result.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
val name = doc.name ?: ""
|
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
|
||||||
updateSafScanProgress {
|
updateSafScanProgress {
|
||||||
it.currentFile = name
|
it.currentFile = name
|
||||||
}
|
}
|
||||||
|
|
||||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
||||||
val tempPath = copyUriToTemp(doc.uri, fallbackExt)
|
val tempPath = try {
|
||||||
|
copyUriToTemp(doc.uri, fallbackExt)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"SpotiFLAC",
|
||||||
|
"SAF incremental scan: failed to copy ${doc.uri}: ${e.message}",
|
||||||
|
)
|
||||||
|
null
|
||||||
|
}
|
||||||
if (tempPath == null) {
|
if (tempPath == null) {
|
||||||
errors++
|
errors++
|
||||||
} else {
|
} else {
|
||||||
@@ -801,9 +1061,10 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
|
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
|
||||||
if (metadataJson.isNotBlank()) {
|
if (metadataJson.isNotBlank()) {
|
||||||
val obj = JSONObject(metadataJson)
|
val obj = JSONObject(metadataJson)
|
||||||
|
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
|
||||||
obj.put("filePath", doc.uri.toString())
|
obj.put("filePath", doc.uri.toString())
|
||||||
obj.put("fileModTime", lastModified)
|
obj.put("fileModTime", safeLastModified)
|
||||||
obj.put("lastModified", lastModified)
|
obj.put("lastModified", safeLastModified)
|
||||||
results.put(obj)
|
results.put(obj)
|
||||||
} else {
|
} else {
|
||||||
errors++
|
errors++
|
||||||
@@ -1051,20 +1312,11 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
"downloadTrack" -> {
|
"downloadByStrategy" -> {
|
||||||
val requestJson = call.arguments as String
|
val requestJson = call.arguments as String
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
handleSafDownload(requestJson) { json ->
|
handleSafDownload(requestJson) { json ->
|
||||||
Gobackend.downloadTrack(json)
|
Gobackend.downloadByStrategy(json)
|
||||||
}
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"downloadWithFallback" -> {
|
|
||||||
val requestJson = call.arguments as String
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
handleSafDownload(requestJson) { json ->
|
|
||||||
Gobackend.downloadWithFallback(json)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
@@ -1240,11 +1492,12 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"safCreateFromPath" -> {
|
"safCreateFromPath" -> {
|
||||||
val treeUriStr = call.argument<String>("tree_uri") ?: ""
|
val treeUriStr = call.argument<String>("tree_uri") ?: ""
|
||||||
val relativeDir = call.argument<String>("relative_dir") ?: ""
|
val relativeDir = call.argument<String>("relative_dir") ?: ""
|
||||||
val fileName = call.argument<String>("file_name") ?: ""
|
val fileName = sanitizeFilename(call.argument<String>("file_name") ?: "")
|
||||||
val mimeType = call.argument<String>("mime_type") ?: "application/octet-stream"
|
val mimeType = call.argument<String>("mime_type") ?: "application/octet-stream"
|
||||||
val srcPath = call.argument<String>("src_path") ?: ""
|
val srcPath = call.argument<String>("src_path") ?: ""
|
||||||
val createdUri = withContext(Dispatchers.IO) {
|
val createdUri = withContext(Dispatchers.IO) {
|
||||||
if (treeUriStr.isBlank()) return@withContext null
|
if (treeUriStr.isBlank()) return@withContext null
|
||||||
|
if (fileName.isBlank()) return@withContext null
|
||||||
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
|
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
|
||||||
val existing = dir.findFile(fileName)
|
val existing = dir.findFile(fileName)
|
||||||
val createdNew = existing == null
|
val createdNew = existing == null
|
||||||
@@ -1329,6 +1582,32 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
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" -> {
|
"embedLyricsToFile" -> {
|
||||||
val filePath = call.argument<String>("file_path") ?: ""
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
val lyrics = call.argument<String>("lyrics") ?: ""
|
val lyrics = call.argument<String>("lyrics") ?: ""
|
||||||
@@ -1372,7 +1651,236 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"readFileMetadata" -> {
|
"readFileMetadata" -> {
|
||||||
val filePath = call.argument<String>("file_path") ?: ""
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.readFileMetadata(filePath)
|
try {
|
||||||
|
if (filePath.startsWith("content://")) {
|
||||||
|
val uri = Uri.parse(filePath)
|
||||||
|
val tempPath = copyUriToTemp(uri)
|
||||||
|
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
|
||||||
|
try {
|
||||||
|
Gobackend.readFileMetadata(tempPath)
|
||||||
|
} finally {
|
||||||
|
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Gobackend.readFileMetadata(filePath)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SpotiFLAC", "readFileMetadata failed: ${e.message}", e)
|
||||||
|
"""{"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"editFileMetadata" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
if (filePath.startsWith("content://")) {
|
||||||
|
val uri = Uri.parse(filePath)
|
||||||
|
val tempPath = copyUriToTemp(uri)
|
||||||
|
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
|
||||||
|
try {
|
||||||
|
val raw = Gobackend.editFileMetadata(tempPath, metadataJson)
|
||||||
|
val obj = JSONObject(raw)
|
||||||
|
val method = obj.optString("method", "")
|
||||||
|
if (method == "ffmpeg") {
|
||||||
|
// MP3/Opus: Dart needs to FFmpeg the temp file, then call writeTempToSaf
|
||||||
|
obj.put("temp_path", tempPath)
|
||||||
|
obj.put("saf_uri", filePath)
|
||||||
|
return@withContext obj.toString()
|
||||||
|
// Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf
|
||||||
|
}
|
||||||
|
// FLAC: Go wrote directly to temp, copy back now
|
||||||
|
if (!writeUriFromPath(uri, tempPath)) {
|
||||||
|
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
|
||||||
|
}
|
||||||
|
raw
|
||||||
|
} catch (e: Exception) {
|
||||||
|
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Gobackend.editFileMetadata(filePath, metadataJson)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SpotiFLAC", "editFileMetadata failed: ${e.message}", e)
|
||||||
|
"""{"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"writeTempToSaf" -> {
|
||||||
|
val tempPath = call.argument<String>("temp_path") ?: ""
|
||||||
|
val safUri = call.argument<String>("saf_uri") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val uri = Uri.parse(safUri)
|
||||||
|
if (writeUriFromPath(uri, tempPath)) {
|
||||||
|
"""{"success":true}"""
|
||||||
|
} else {
|
||||||
|
"""{"success":false,"error":"Failed to write back to SAF"}"""
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"downloadCoverToFile" -> {
|
||||||
|
val coverUrl = call.argument<String>("cover_url") ?: ""
|
||||||
|
val outputPath = call.argument<String>("output_path") ?: ""
|
||||||
|
val maxQuality = call.argument<Boolean>("max_quality") ?: true
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Gobackend.downloadCoverToFile(coverUrl, outputPath, maxQuality)
|
||||||
|
"""{"success":true}"""
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"extractCoverToFile" -> {
|
||||||
|
val audioPath = call.argument<String>("audio_path") ?: ""
|
||||||
|
val outputPath = call.argument<String>("output_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
if (audioPath.startsWith("content://")) {
|
||||||
|
val uri = Uri.parse(audioPath)
|
||||||
|
val tempPath = copyUriToTemp(uri)
|
||||||
|
?: return@withContext """{"success":false,"error":"Failed to copy SAF file to temp"}"""
|
||||||
|
try {
|
||||||
|
Gobackend.extractCoverToFile(tempPath, outputPath)
|
||||||
|
"""{"success":true}"""
|
||||||
|
} finally {
|
||||||
|
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Gobackend.extractCoverToFile(audioPath, outputPath)
|
||||||
|
"""{"success":true}"""
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"fetchAndSaveLyrics" -> {
|
||||||
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
|
val durationMs = call.argument<Number>("duration_ms")?.toLong() ?: 0L
|
||||||
|
val outputPath = call.argument<String>("output_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Gobackend.fetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath)
|
||||||
|
"""{"success":true}"""
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
val reqObj = JSONObject(requestJson)
|
||||||
|
val filePath = reqObj.optString("file_path", "")
|
||||||
|
|
||||||
|
if (filePath.startsWith("content://")) {
|
||||||
|
val uri = Uri.parse(filePath)
|
||||||
|
val tempPath = copyUriToTemp(uri)
|
||||||
|
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
|
||||||
|
try {
|
||||||
|
// Replace file_path with temp path for Go
|
||||||
|
reqObj.put("file_path", tempPath)
|
||||||
|
val raw = Gobackend.reEnrichFile(reqObj.toString())
|
||||||
|
val obj = JSONObject(raw)
|
||||||
|
|
||||||
|
if (obj.has("error")) {
|
||||||
|
return@withContext raw
|
||||||
|
}
|
||||||
|
|
||||||
|
val method = obj.optString("method", "")
|
||||||
|
if (method == "ffmpeg") {
|
||||||
|
// MP3/Opus: Dart handles FFmpeg on temp file, then writes back
|
||||||
|
obj.put("temp_path", tempPath)
|
||||||
|
obj.put("saf_uri", filePath)
|
||||||
|
return@withContext obj.toString()
|
||||||
|
// temp file NOT deleted - Dart cleans up after FFmpeg + writeTempToSaf
|
||||||
|
}
|
||||||
|
|
||||||
|
// FLAC: Go wrote directly to temp, copy back now
|
||||||
|
if (!writeUriFromPath(uri, tempPath)) {
|
||||||
|
return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}"""
|
||||||
|
}
|
||||||
|
raw
|
||||||
|
} catch (e: Exception) {
|
||||||
|
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Gobackend.reEnrichFile(requestJson)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"""{"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
@@ -1693,15 +2201,6 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
"downloadWithExtensions" -> {
|
|
||||||
val requestJson = call.arguments as String
|
|
||||||
val response = withContext(Dispatchers.IO) {
|
|
||||||
handleSafDownload(requestJson) { json ->
|
|
||||||
Gobackend.downloadWithExtensionsJSON(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.success(response)
|
|
||||||
}
|
|
||||||
"enrichTrackWithExtension" -> {
|
"enrichTrackWithExtension" -> {
|
||||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
val trackJson = call.argument<String>("track") ?: "{}"
|
val trackJson = call.argument<String>("track") ?: "{}"
|
||||||
@@ -2024,7 +2523,22 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"readAudioMetadata" -> {
|
"readAudioMetadata" -> {
|
||||||
val filePath = call.argument<String>("file_path") ?: ""
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.readAudioMetadataJSON(filePath)
|
try {
|
||||||
|
if (filePath.startsWith("content://")) {
|
||||||
|
val uri = Uri.parse(filePath)
|
||||||
|
val tempPath = copyUriToTemp(uri)
|
||||||
|
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
|
||||||
|
try {
|
||||||
|
Gobackend.readAudioMetadataJSON(tempPath)
|
||||||
|
} finally {
|
||||||
|
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Gobackend.readAudioMetadataJSON(filePath)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"""{"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ subprojects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add desugaring dependency to all Android subprojects
|
// Add desugaring dependency to all Android subprojects
|
||||||
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
|
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.13.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 122 KiB |
@@ -31,6 +31,8 @@ type AmazonDownloader struct {
|
|||||||
var (
|
var (
|
||||||
globalAmazonDownloader *AmazonDownloader
|
globalAmazonDownloader *AmazonDownloader
|
||||||
amazonDownloaderOnce sync.Once
|
amazonDownloaderOnce sync.Once
|
||||||
|
amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`)
|
||||||
|
amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// AfkarXYZResponse is the response from AfkarXYZ API
|
// AfkarXYZResponse is the response from AfkarXYZ API
|
||||||
@@ -43,6 +45,12 @@ type AfkarXYZResponse struct {
|
|||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin}
|
||||||
|
type AmazonStreamResponse struct {
|
||||||
|
StreamURL string `json:"streamUrl"`
|
||||||
|
DecryptionKey string `json:"decryptionKey"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
amazonDownloaderOnce.Do(func() {
|
amazonDownloaderOnce.Do(func() {
|
||||||
globalAmazonDownloader = &AmazonDownloader{
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
@@ -52,10 +60,9 @@ func NewAmazonDownloader() *AmazonDownloader {
|
|||||||
return globalAmazonDownloader
|
return globalAmazonDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks
|
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
|
||||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) {
|
// Returns downloadURL, suggested fileName, optional decryptionKey.
|
||||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
@@ -64,66 +71,184 @@ func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, st
|
|||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL)
|
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return downloadURL, fileName, nil
|
return downloadURL, fileName, decryptionKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
errStr := err.Error()
|
errStr := strings.ToLower(err.Error())
|
||||||
|
|
||||||
// Check if error is retryable
|
// Check if error is retryable
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||||
strings.Contains(errStr, "connection reset") ||
|
strings.Contains(errStr, "connection reset") ||
|
||||||
strings.Contains(errStr, "connection refused") ||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
strings.Contains(errStr, "EOF") ||
|
strings.Contains(errStr, "eof") ||
|
||||||
strings.Contains(errStr, "status 5") ||
|
strings.Contains(errStr, "status 5") ||
|
||||||
strings.Contains(errStr, "status 429")
|
strings.Contains(errStr, "status 429") ||
|
||||||
|
strings.Contains(errStr, "http 429")
|
||||||
|
|
||||||
if !isRetryable {
|
if !isRetryable {
|
||||||
return "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// doAfkarXYZRequest performs a single request to AfkarXYZ API
|
func normalizeAmazonASIN(candidate string) string {
|
||||||
func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) {
|
trimmed := strings.TrimSpace(candidate)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded, err := url.QueryUnescape(trimmed); err == nil {
|
||||||
|
trimmed = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed = strings.ToUpper(trimmed)
|
||||||
|
if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 {
|
||||||
|
trimmed = trimmed[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if amazonASINRegex.MatchString(trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAmazonASIN(amazonURL string) string {
|
||||||
|
raw := strings.TrimSpace(amazonURL)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err == nil {
|
||||||
|
query := parsed.Query()
|
||||||
|
|
||||||
|
// Prefer track-level ASIN when URL also contains albumAsin.
|
||||||
|
for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} {
|
||||||
|
if asin := normalizeAmazonASIN(query.Get(key)); asin != "" {
|
||||||
|
return asin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.Trim(parsed.Path, "/")
|
||||||
|
if path != "" {
|
||||||
|
segments := strings.Split(path, "/")
|
||||||
|
|
||||||
|
for i := 0; i < len(segments)-1; i++ {
|
||||||
|
segment := strings.ToLower(strings.TrimSpace(segments[i]))
|
||||||
|
if segment == "track" || segment == "tracks" {
|
||||||
|
if asin := normalizeAmazonASIN(segments[i+1]); asin != "" {
|
||||||
|
return asin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" {
|
||||||
|
return asin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match := amazonASINFindRegex.FindString(strings.ToUpper(raw))
|
||||||
|
return normalizeAmazonASIN(match)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doAfkarXYZRequest performs a single request to Amazon API.
|
||||||
|
// It tries new endpoint first, then falls back to legacy /convert endpoint.
|
||||||
|
func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) {
|
||||||
|
asin := extractAmazonASIN(amazonURL)
|
||||||
|
if asin != "" {
|
||||||
|
GoLog("[Amazon] Using ASIN: %s\n", asin)
|
||||||
|
downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin)
|
||||||
|
if err == nil {
|
||||||
|
return downloadURL, fileName, decryptKey, nil
|
||||||
|
}
|
||||||
|
GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err)
|
||||||
|
}
|
||||||
|
return a.doAfkarXYZRequestLegacy(amazonURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to create request: %w", err)
|
return "", "", "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResp AmazonStreamResponse
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(apiResp.StreamURL) == "" {
|
||||||
|
return "", "", "", fmt.Errorf("Amazon API returned empty stream URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := asin + ".m4a"
|
||||||
|
return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
|
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
|
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to read response: %w", err)
|
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiResp AfkarXYZResponse
|
var apiResp AfkarXYZResponse
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
return "", "", fmt.Errorf("failed to decode response: %w", err)
|
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !apiResp.Success || apiResp.Data.DirectLink == "" {
|
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
|
||||||
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found")
|
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
|
||||||
}
|
}
|
||||||
|
|
||||||
fileName := apiResp.Data.FileName
|
fileName := apiResp.Data.FileName
|
||||||
@@ -134,19 +259,22 @@ func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, err
|
|||||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||||
fileName = reg.ReplaceAllString(fileName, "")
|
fileName = reg.ReplaceAllString(fileName, "")
|
||||||
|
|
||||||
return apiResp.Data.DirectLink, fileName, nil
|
return apiResp.Data.DirectLink, fileName, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
|
||||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||||
|
|
||||||
downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL)
|
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decryptionKey != "" {
|
||||||
|
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
|
||||||
|
}
|
||||||
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
||||||
return downloadURL, fileName, nil
|
return downloadURL, fileName, decryptionKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||||
@@ -233,17 +361,18 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD
|
|||||||
|
|
||||||
// AmazonDownloadResult contains download result with quality info
|
// AmazonDownloadResult contains download result with quality info
|
||||||
type AmazonDownloadResult struct {
|
type AmazonDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
Album string
|
Album string
|
||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
LyricsLRC string
|
LyricsLRC string
|
||||||
|
DecryptionKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
@@ -299,7 +428,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download using AfkarXYZ API
|
// Download using AfkarXYZ API
|
||||||
downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
||||||
}
|
}
|
||||||
@@ -312,6 +441,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
"track": req.TrackNumber,
|
"track": req.TrackNumber,
|
||||||
"year": extractYear(req.ReleaseDate),
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
"disc": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
})
|
})
|
||||||
var outputPath string
|
var outputPath string
|
||||||
@@ -321,7 +451,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
filename = sanitizeFilename(filename) + ".flac"
|
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
|
||||||
|
if outputExt == "" {
|
||||||
|
outputExt = ".flac"
|
||||||
|
}
|
||||||
|
filename = sanitizeFilename(filename) + outputExt
|
||||||
outputPath = filepath.Join(req.OutputDir, filename)
|
outputPath = filepath.Join(req.OutputDir, filename)
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
@@ -352,6 +486,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actualOutputPath := outputPath
|
||||||
|
needsDecryption := strings.TrimSpace(decryptionKey) != ""
|
||||||
|
if needsDecryption {
|
||||||
|
GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n")
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for parallel operations to complete
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
@@ -360,7 +500,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
|
||||||
actualTrackNum := req.TrackNumber
|
actualTrackNum := req.TrackNumber
|
||||||
actualDiscNum := req.DiscNumber
|
actualDiscNum := req.DiscNumber
|
||||||
actualDate := req.ReleaseDate
|
actualDate := req.ReleaseDate
|
||||||
@@ -368,25 +507,28 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
actualTitle := req.TrackName
|
actualTitle := req.TrackName
|
||||||
actualArtist := req.ArtistName
|
actualArtist := req.ArtistName
|
||||||
|
|
||||||
if metaErr == nil && existingMeta != nil {
|
if !needsDecryption {
|
||||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
existingMeta, metaErr := ReadMetadata(actualOutputPath)
|
||||||
actualTrackNum = existingMeta.TrackNumber
|
if metaErr == nil && existingMeta != nil {
|
||||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||||
|
actualTrackNum = existingMeta.TrackNumber
|
||||||
|
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||||
|
}
|
||||||
|
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
||||||
|
actualDiscNum = existingMeta.DiscNumber
|
||||||
|
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||||
|
}
|
||||||
|
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
||||||
|
actualDate = existingMeta.Date
|
||||||
|
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
||||||
|
}
|
||||||
|
if existingMeta.Album != "" && req.AlbumName == "" {
|
||||||
|
actualAlbum = existingMeta.Album
|
||||||
|
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
||||||
|
}
|
||||||
|
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
||||||
|
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
||||||
}
|
}
|
||||||
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
|
||||||
actualDiscNum = existingMeta.DiscNumber
|
|
||||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
|
||||||
}
|
|
||||||
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
|
||||||
actualDate = existingMeta.Date
|
|
||||||
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
|
||||||
}
|
|
||||||
if existingMeta.Album != "" && req.AlbumName == "" {
|
|
||||||
actualAlbum = existingMeta.Album
|
|
||||||
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
|
||||||
}
|
|
||||||
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
|
||||||
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
@@ -409,7 +551,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
} else {
|
} else {
|
||||||
existingCover, coverErr := ExtractCoverArt(outputPath)
|
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
|
||||||
if coverErr == nil && len(existingCover) > 0 {
|
if coverErr == nil && len(existingCover) > 0 {
|
||||||
coverData = existingCover
|
coverData = existingCover
|
||||||
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
||||||
@@ -418,11 +560,16 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if isSafOutput {
|
if isSafOutput || needsDecryption {
|
||||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||||
} else {
|
} else {
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
|
||||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
if isFlacOutput {
|
||||||
|
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||||
|
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
@@ -433,20 +580,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
if lyricsMode == "external" || lyricsMode == "both" {
|
if lyricsMode == "external" || lyricsMode == "both" {
|
||||||
GoLog("[Amazon] Saving external LRC file...\n")
|
GoLog("[Amazon] Saving external LRC file...\n")
|
||||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
|
||||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||||
}
|
}
|
||||||
|
} else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput {
|
||||||
|
GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n")
|
||||||
}
|
}
|
||||||
} else if req.EmbedLyrics {
|
} else if req.EmbedLyrics {
|
||||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||||
@@ -456,17 +605,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
||||||
|
|
||||||
quality := AudioQuality{}
|
quality := AudioQuality{}
|
||||||
if isSafOutput {
|
if isSafOutput || needsDecryption {
|
||||||
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
||||||
} else {
|
} else {
|
||||||
quality, err = GetAudioQuality(outputPath)
|
quality, err = GetAudioQuality(actualOutputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
|
||||||
if metaReadErr == nil && finalMeta != nil {
|
if metaReadErr == nil && finalMeta != nil {
|
||||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||||
@@ -478,9 +627,10 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to ISRC index for fast duplicate checking
|
// Add to ISRC index for fast duplicate checking.
|
||||||
if !isSafOutput {
|
// When decryption is pending in Flutter, postpone indexing until final file is settled.
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
if !isSafOutput && !needsDecryption {
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
bitDepth := 0
|
bitDepth := 0
|
||||||
@@ -496,16 +646,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return AmazonDownloadResult{
|
return AmazonDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: bitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: sampleRate,
|
SampleRate: sampleRate,
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
ReleaseDate: req.ReleaseDate,
|
ReleaseDate: req.ReleaseDate,
|
||||||
TrackNumber: actualTrackNum,
|
TrackNumber: actualTrackNum,
|
||||||
DiscNumber: actualDiscNum,
|
DiscNumber: actualDiscNum,
|
||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
LyricsLRC: lyricsLRC,
|
LyricsLRC: lyricsLRC,
|
||||||
|
DecryptionKey: decryptionKey,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractAmazonASIN(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "prefers trackAsin over albumAsin",
|
||||||
|
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
|
||||||
|
want: "B0TRACK456",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extract from tracks path",
|
||||||
|
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
|
||||||
|
want: "B0CYQHGWZJ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extract from plain query asin",
|
||||||
|
url: "https://example.com/?asin=B0CYQHGWZJ",
|
||||||
|
want: "B0CYQHGWZJ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fallback regex",
|
||||||
|
url: "https://example.com/path/B0CYQHGWZJ",
|
||||||
|
want: "B0CYQHGWZJ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid url",
|
||||||
|
url: "https://music.amazon.com/tracks/not-valid",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := extractAmazonASIN(tt.url)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,11 @@ type AudioMetadata struct {
|
|||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
|
Lyrics string
|
||||||
|
Label string
|
||||||
|
Copyright string
|
||||||
|
Composer string
|
||||||
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
// MP3Quality represents MP3 specific quality info
|
// MP3Quality represents MP3 specific quality info
|
||||||
@@ -38,6 +43,7 @@ type OggQuality struct {
|
|||||||
SampleRate int
|
SampleRate int
|
||||||
BitDepth int
|
BitDepth int
|
||||||
Duration int
|
Duration int
|
||||||
|
Bitrate int // estimated bitrate in bps
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -171,6 +177,21 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
|||||||
metadata.TrackNumber = parseTrackNumber(value)
|
metadata.TrackNumber = parseTrackNumber(value)
|
||||||
case "TPA":
|
case "TPA":
|
||||||
metadata.DiscNumber = parseTrackNumber(value)
|
metadata.DiscNumber = parseTrackNumber(value)
|
||||||
|
case "TCM":
|
||||||
|
metadata.Composer = value
|
||||||
|
case "TPB":
|
||||||
|
metadata.Label = value
|
||||||
|
case "TCR":
|
||||||
|
metadata.Copyright = value
|
||||||
|
case "ULT":
|
||||||
|
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = v
|
||||||
|
}
|
||||||
|
case "TXX":
|
||||||
|
desc, userValue := extractUserTextFrame(frameData)
|
||||||
|
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = userValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pos += 6 + frameSize
|
pos += 6 + frameSize
|
||||||
@@ -277,6 +298,25 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
|||||||
metadata.DiscNumber = parseTrackNumber(value)
|
metadata.DiscNumber = parseTrackNumber(value)
|
||||||
case "TSRC":
|
case "TSRC":
|
||||||
metadata.ISRC = value
|
metadata.ISRC = value
|
||||||
|
case "TCOM":
|
||||||
|
metadata.Composer = value
|
||||||
|
case "TPUB":
|
||||||
|
metadata.Label = value
|
||||||
|
case "TCOP":
|
||||||
|
metadata.Copyright = value
|
||||||
|
case "COMM":
|
||||||
|
if v := extractCommentFrame(frameData); v != "" {
|
||||||
|
metadata.Comment = v
|
||||||
|
}
|
||||||
|
case "USLT":
|
||||||
|
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = v
|
||||||
|
}
|
||||||
|
case "TXXX":
|
||||||
|
desc, userValue := extractUserTextFrame(frameData)
|
||||||
|
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = userValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pos += 10 + frameSize
|
pos += 10 + frameSize
|
||||||
@@ -339,6 +379,138 @@ func extractTextFrame(data []byte) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractCommentFrame parses an ID3v2 COMM frame.
|
||||||
|
// Format: encoding(1) + language(3) + description(null-terminated) + text
|
||||||
|
func extractCommentFrame(data []byte) string {
|
||||||
|
if len(data) < 5 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
encoding := data[0]
|
||||||
|
// skip 3-byte language code
|
||||||
|
rest := data[4:]
|
||||||
|
|
||||||
|
// find null terminator separating description from text
|
||||||
|
var text []byte
|
||||||
|
switch encoding {
|
||||||
|
case 1, 2: // UTF-16 variants use double-null terminator
|
||||||
|
for i := 0; i+1 < len(rest); i += 2 {
|
||||||
|
if rest[i] == 0 && rest[i+1] == 0 {
|
||||||
|
text = rest[i+2:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: // ISO-8859-1 or UTF-8
|
||||||
|
idx := bytes.IndexByte(rest, 0)
|
||||||
|
if idx >= 0 && idx+1 < len(rest) {
|
||||||
|
text = rest[idx+1:]
|
||||||
|
} else {
|
||||||
|
text = rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(text) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// re-prepend encoding byte so extractTextFrame can decode properly
|
||||||
|
framed := make([]byte, 1+len(text))
|
||||||
|
framed[0] = encoding
|
||||||
|
copy(framed[1:], text)
|
||||||
|
return extractTextFrame(framed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractLyricsFrame parses ID3 unsynchronized lyrics frames (USLT/ULT).
|
||||||
|
// Format: encoding(1) + language(3) + description(null-terminated) + lyrics text.
|
||||||
|
func extractLyricsFrame(data []byte) string {
|
||||||
|
if len(data) < 5 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
encoding := data[0]
|
||||||
|
rest := data[4:] // skip 3-byte language code
|
||||||
|
|
||||||
|
var text []byte
|
||||||
|
switch encoding {
|
||||||
|
case 1, 2: // UTF-16 variants use double-null terminator
|
||||||
|
for i := 0; i+1 < len(rest); i += 2 {
|
||||||
|
if rest[i] == 0 && rest[i+1] == 0 {
|
||||||
|
text = rest[i+2:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: // ISO-8859-1 or UTF-8
|
||||||
|
idx := bytes.IndexByte(rest, 0)
|
||||||
|
if idx >= 0 && idx+1 < len(rest) {
|
||||||
|
text = rest[idx+1:]
|
||||||
|
} else {
|
||||||
|
text = rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(text) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
framed := make([]byte, 1+len(text))
|
||||||
|
framed[0] = encoding
|
||||||
|
copy(framed[1:], text)
|
||||||
|
return extractTextFrame(framed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractUserTextFrame parses ID3 TXXX/TXX user text frame:
|
||||||
|
// encoding(1) + description + separator + value.
|
||||||
|
func extractUserTextFrame(data []byte) (string, string) {
|
||||||
|
if len(data) < 2 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
encoding := data[0]
|
||||||
|
payload := data[1:]
|
||||||
|
|
||||||
|
var descRaw, valueRaw []byte
|
||||||
|
switch encoding {
|
||||||
|
case 1, 2: // UTF-16 variants
|
||||||
|
for i := 0; i+1 < len(payload); i += 2 {
|
||||||
|
if payload[i] == 0 && payload[i+1] == 0 {
|
||||||
|
descRaw = payload[:i]
|
||||||
|
valueRaw = payload[i+2:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: // ISO-8859-1 or UTF-8
|
||||||
|
idx := bytes.IndexByte(payload, 0)
|
||||||
|
if idx >= 0 {
|
||||||
|
descRaw = payload[:idx]
|
||||||
|
if idx+1 <= len(payload) {
|
||||||
|
valueRaw = payload[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(valueRaw) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
descFramed := make([]byte, 1+len(descRaw))
|
||||||
|
descFramed[0] = encoding
|
||||||
|
copy(descFramed[1:], descRaw)
|
||||||
|
|
||||||
|
valueFramed := make([]byte, 1+len(valueRaw))
|
||||||
|
valueFramed[0] = encoding
|
||||||
|
copy(valueFramed[1:], valueRaw)
|
||||||
|
|
||||||
|
return strings.TrimSpace(extractTextFrame(descFramed)), strings.TrimSpace(extractTextFrame(valueFramed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLyricsDescription(description string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(description)) {
|
||||||
|
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func decodeUTF16(data []byte) string {
|
func decodeUTF16(data []byte) string {
|
||||||
if len(data) < 2 {
|
if len(data) < 2 {
|
||||||
return ""
|
return ""
|
||||||
@@ -493,50 +665,144 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
|
|
||||||
file.Seek(audioStart, io.SeekStart)
|
file.Seek(audioStart, io.SeekStart)
|
||||||
|
|
||||||
|
// Find first valid MP3 frame sync
|
||||||
frameHeader := make([]byte, 4)
|
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 {
|
if _, err := io.ReadFull(file, frameHeader); err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 {
|
if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 {
|
||||||
version := (frameHeader[1] >> 3) & 0x03
|
pos, _ := file.Seek(0, io.SeekCurrent)
|
||||||
layer := (frameHeader[1] >> 1) & 0x03
|
frameStart = pos - 4
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
file.Seek(-3, io.SeekCurrent)
|
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
|
return quality, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,9 +1006,16 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if commentLen > 10000 {
|
remaining := uint32(reader.Len())
|
||||||
|
if commentLen > remaining {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// Large comment entries are typically METADATA_BLOCK_PICTURE.
|
||||||
|
// Skip them so we can continue parsing normal text tags after/before.
|
||||||
|
if commentLen > 512*1024 {
|
||||||
|
reader.Seek(int64(commentLen), io.SeekCurrent)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
comment := make([]byte, commentLen)
|
comment := make([]byte, commentLen)
|
||||||
if _, err := reader.Read(comment); err != nil {
|
if _, err := reader.Read(comment); err != nil {
|
||||||
@@ -779,6 +1052,18 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
metadata.DiscNumber = parseTrackNumber(value)
|
metadata.DiscNumber = parseTrackNumber(value)
|
||||||
case "ISRC":
|
case "ISRC":
|
||||||
metadata.ISRC = value
|
metadata.ISRC = value
|
||||||
|
case "COMPOSER":
|
||||||
|
metadata.Composer = value
|
||||||
|
case "COMMENT", "DESCRIPTION":
|
||||||
|
metadata.Comment = value
|
||||||
|
case "LYRICS", "UNSYNCEDLYRICS":
|
||||||
|
if metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = value
|
||||||
|
}
|
||||||
|
case "ORGANIZATION", "LABEL", "PUBLISHER":
|
||||||
|
metadata.Label = value
|
||||||
|
case "COPYRIGHT":
|
||||||
|
metadata.Copyright = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -791,7 +1076,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
|||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
quality := &OggQuality{}
|
quality := &OggQuality{}
|
||||||
isOpus := false
|
|
||||||
|
|
||||||
packets, err := collectOggPackets(file, 5, 10)
|
packets, err := collectOggPackets(file, 5, 10)
|
||||||
if err != nil && len(packets) == 0 {
|
if err != nil && len(packets) == 0 {
|
||||||
@@ -807,15 +1091,17 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if streamType == oggStreamOpus {
|
isOpus := streamType == oggStreamOpus
|
||||||
isOpus = true
|
var preSkip int
|
||||||
|
|
||||||
|
if isOpus {
|
||||||
for _, pkt := range packets {
|
for _, pkt := range packets {
|
||||||
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
|
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
|
||||||
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
||||||
if quality.SampleRate == 0 {
|
if quality.SampleRate == 0 {
|
||||||
quality.SampleRate = 48000
|
quality.SampleRate = 48000
|
||||||
}
|
}
|
||||||
quality.BitDepth = 16
|
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -823,26 +1109,76 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
|||||||
for _, pkt := range packets {
|
for _, pkt := range packets {
|
||||||
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
|
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
|
||||||
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
||||||
quality.BitDepth = 16
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read granule position from the last Ogg page for accurate duration
|
||||||
stat, err := file.Stat()
|
stat, err := file.Stat()
|
||||||
if err == nil {
|
if err != nil {
|
||||||
// Very rough duration estimate based on file size
|
return quality, nil
|
||||||
// Assume ~128kbps average for Opus, ~160kbps for Vorbis
|
}
|
||||||
avgBitrate := 128000
|
fileSize := stat.Size()
|
||||||
if !isOpus {
|
|
||||||
avgBitrate = 160000
|
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
|
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
|
// ID3v1 Genre List
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -28,15 +28,23 @@ const (
|
|||||||
deezerAPITimeoutMobile = 25 * time.Second
|
deezerAPITimeoutMobile = 25 * time.Second
|
||||||
deezerMaxRetries = 2
|
deezerMaxRetries = 2
|
||||||
deezerRetryDelay = 500 * time.Millisecond
|
deezerRetryDelay = 500 * time.Millisecond
|
||||||
|
|
||||||
|
deezerMaxSearchCacheEntries = 300
|
||||||
|
deezerMaxAlbumCacheEntries = 200
|
||||||
|
deezerMaxArtistCacheEntries = 200
|
||||||
|
deezerMaxISRCCacheEntries = 4000
|
||||||
|
deezerCacheCleanupInterval = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type DeezerClient struct {
|
type DeezerClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
searchCache map[string]*cacheEntry
|
searchCache map[string]*cacheEntry
|
||||||
albumCache map[string]*cacheEntry
|
albumCache map[string]*cacheEntry
|
||||||
artistCache map[string]*cacheEntry
|
artistCache map[string]*cacheEntry
|
||||||
isrcCache map[string]string
|
isrcCache map[string]string
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
|
lastCacheCleanup time.Time
|
||||||
|
cacheCleanupInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -47,16 +55,111 @@ var (
|
|||||||
func GetDeezerClient() *DeezerClient {
|
func GetDeezerClient() *DeezerClient {
|
||||||
deezerClientOnce.Do(func() {
|
deezerClientOnce.Do(func() {
|
||||||
deezerClient = &DeezerClient{
|
deezerClient = &DeezerClient{
|
||||||
httpClient: NewHTTPClientWithTimeout(deezerAPITimeoutMobile),
|
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
|
||||||
searchCache: make(map[string]*cacheEntry),
|
searchCache: make(map[string]*cacheEntry),
|
||||||
albumCache: make(map[string]*cacheEntry),
|
albumCache: make(map[string]*cacheEntry),
|
||||||
artistCache: make(map[string]*cacheEntry),
|
artistCache: make(map[string]*cacheEntry),
|
||||||
isrcCache: make(map[string]string),
|
isrcCache: make(map[string]string),
|
||||||
|
cacheCleanupInterval: deezerCacheCleanupInterval,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return deezerClient
|
return deezerClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) pruneExpiredCacheEntriesLocked(
|
||||||
|
cache map[string]*cacheEntry,
|
||||||
|
now time.Time,
|
||||||
|
) {
|
||||||
|
for key, entry := range cache {
|
||||||
|
if entry == nil || now.After(entry.expiresAt) {
|
||||||
|
delete(cache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) trimCacheEntriesLocked(
|
||||||
|
cache map[string]*cacheEntry,
|
||||||
|
maxEntries int,
|
||||||
|
) {
|
||||||
|
if maxEntries <= 0 || len(cache) <= maxEntries {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(cache) > maxEntries {
|
||||||
|
var oldestKey string
|
||||||
|
var oldestExpiry time.Time
|
||||||
|
first := true
|
||||||
|
for key, entry := range cache {
|
||||||
|
expiry := time.Time{}
|
||||||
|
if entry != nil {
|
||||||
|
expiry = entry.expiresAt
|
||||||
|
}
|
||||||
|
if first || expiry.Before(oldestExpiry) {
|
||||||
|
first = false
|
||||||
|
oldestKey = key
|
||||||
|
oldestExpiry = expiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldestKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(cache, oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) trimStringCacheEntriesLocked(
|
||||||
|
cache map[string]string,
|
||||||
|
maxEntries int,
|
||||||
|
) {
|
||||||
|
if maxEntries <= 0 || len(cache) <= maxEntries {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toRemove := len(cache) - maxEntries
|
||||||
|
for key := range cache {
|
||||||
|
delete(cache, key)
|
||||||
|
toRemove--
|
||||||
|
if toRemove <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
|
||||||
|
periodicCleanupDue := c.cacheCleanupInterval > 0 &&
|
||||||
|
(c.lastCacheCleanup.IsZero() ||
|
||||||
|
now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval)
|
||||||
|
|
||||||
|
if periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
||||||
|
c.lastCacheCleanup = now
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.searchCache) > deezerMaxSearchCacheEntries {
|
||||||
|
if !periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
||||||
|
}
|
||||||
|
c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries)
|
||||||
|
}
|
||||||
|
if len(c.albumCache) > deezerMaxAlbumCacheEntries {
|
||||||
|
if !periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
||||||
|
}
|
||||||
|
c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries)
|
||||||
|
}
|
||||||
|
if len(c.artistCache) > deezerMaxArtistCacheEntries {
|
||||||
|
if !periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
||||||
|
}
|
||||||
|
c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries)
|
||||||
|
}
|
||||||
|
if len(c.isrcCache) > deezerMaxISRCCacheEntries {
|
||||||
|
c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type deezerTrack struct {
|
type deezerTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -414,10 +517,12 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
|
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -555,10 +660,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.albumCache[albumID] = &cacheEntry{
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -638,10 +745,12 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.artistCache[artistID] = &cacheEntry{
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -807,6 +916,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
for trackIDStr, isrc := range directISRCs {
|
for trackIDStr, isrc := range directISRCs {
|
||||||
c.isrcCache[trackIDStr] = isrc
|
c.isrcCache[trackIDStr] = isrc
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(time.Now())
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,6 +951,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
||||||
|
c.maybeCleanupCachesLocked(time.Now())
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
}(track)
|
}(track)
|
||||||
}
|
}
|
||||||
@@ -864,6 +975,7 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
|||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.isrcCache[trackID] = fullTrack.ISRC
|
c.isrcCache[trackID] = fullTrack.ISRC
|
||||||
|
c.maybeCleanupCachesLocked(time.Now())
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return fullTrack.ISRC, nil
|
return fullTrack.ISRC, nil
|
||||||
@@ -946,10 +1058,12 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||||
|
|||||||
@@ -713,6 +713,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
Permissions []string `json:"permissions"`
|
Permissions []string `json:"permissions"`
|
||||||
HasMetadataProvider bool `json:"has_metadata_provider"`
|
HasMetadataProvider bool `json:"has_metadata_provider"`
|
||||||
HasDownloadProvider bool `json:"has_download_provider"`
|
HasDownloadProvider bool `json:"has_download_provider"`
|
||||||
|
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||||
@@ -770,6 +771,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
HasMetadataProvider: ext.Manifest.IsMetadataProvider(),
|
HasMetadataProvider: ext.Manifest.IsMetadataProvider(),
|
||||||
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
||||||
|
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
||||||
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||||
TrackMatching: ext.Manifest.TrackMatching,
|
TrackMatching: ext.Manifest.TrackMatching,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type ExtensionType string
|
|||||||
const (
|
const (
|
||||||
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
||||||
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||||
|
ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SettingType string
|
type SettingType string
|
||||||
@@ -167,10 +168,10 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, t := range m.Types {
|
for _, t := range m.Types {
|
||||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider {
|
||||||
return &ManifestValidationError{
|
return &ManifestValidationError{
|
||||||
Field: "type",
|
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)
|
return m.HasType(ExtensionTypeDownloadProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) IsLyricsProvider() bool {
|
||||||
|
return m.HasType(ExtensionTypeLyricsProvider)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
for _, allowed := range m.Permissions.Network {
|
for _, allowed := range m.Permissions.Network {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -1082,16 +1083,18 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
Title: amazonResult.Title,
|
Title: amazonResult.Title,
|
||||||
Artist: amazonResult.Artist,
|
Artist: amazonResult.Artist,
|
||||||
Album: amazonResult.Album,
|
Album: amazonResult.Album,
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
ISRC: amazonResult.ISRC,
|
ISRC: amazonResult.ISRC,
|
||||||
|
LyricsLRC: amazonResult.LyricsLRC,
|
||||||
|
DecryptionKey: amazonResult.DecryptionKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
@@ -1119,6 +1122,8 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
|
LyricsLRC: result.LyricsLRC,
|
||||||
|
DecryptionKey: result.DecryptionKey,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1132,8 +1137,13 @@ func buildOutputPath(req DownloadRequest) string {
|
|||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
"album_artist": req.AlbumArtist,
|
"album_artist": req.AlbumArtist,
|
||||||
|
"track": req.TrackNumber,
|
||||||
"track_number": req.TrackNumber,
|
"track_number": req.TrackNumber,
|
||||||
|
"disc": req.DiscNumber,
|
||||||
"disc_number": req.DiscNumber,
|
"disc_number": req.DiscNumber,
|
||||||
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
|
"release_date": req.ReleaseDate,
|
||||||
"isrc": req.ISRC,
|
"isrc": req.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1164,16 +1174,30 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
optionsJSON, _ := json.Marshal(options)
|
if options == nil {
|
||||||
|
options = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
// Avoid embedding user input directly into JS source. Some inputs can trigger
|
||||||
|
// parser/runtime edge cases on specific devices/Goja builds.
|
||||||
|
const queryVar = "__sf_custom_search_query"
|
||||||
|
const optionsVar = "__sf_custom_search_options"
|
||||||
|
global := p.vm.GlobalObject()
|
||||||
|
_ = global.Set(queryVar, query)
|
||||||
|
_ = global.Set(optionsVar, options)
|
||||||
|
defer func() {
|
||||||
|
global.Delete(queryVar)
|
||||||
|
global.Delete(optionsVar)
|
||||||
|
}()
|
||||||
|
|
||||||
|
const script = `
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
|
||||||
return extension.customSearch(%q, %s);
|
return extension.customSearch(__sf_custom_search_query, __sf_custom_search_options);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})()
|
})()
|
||||||
`, query, string(optionsJSON))
|
`
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1358,12 +1382,12 @@ type PostProcessResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PostProcessInput struct {
|
type PostProcessInput struct {
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
URI string `json:"uri,omitempty"`
|
URI string `json:"uri,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
MimeType string `json:"mime_type,omitempty"`
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
Size int64 `json:"size,omitempty"`
|
Size int64 `json:"size,omitempty"`
|
||||||
IsSAF bool `json:"is_saf,omitempty"`
|
IsSAF bool `json:"is_saf,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostProcessTimeout = 2 * time.Minute
|
const PostProcessTimeout = 2 * time.Minute
|
||||||
@@ -1676,3 +1700,140 @@ func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata
|
|||||||
|
|
||||||
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,43 @@ import (
|
|||||||
|
|
||||||
// ==================== Auth API (OAuth Support) ====================
|
// ==================== Auth API (OAuth Support) ====================
|
||||||
|
|
||||||
|
func validateExtensionAuthURL(urlStr string) error {
|
||||||
|
parsed, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid auth URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Scheme != "https" {
|
||||||
|
return fmt.Errorf("invalid auth URL: only https is allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
host := parsed.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return fmt.Errorf("invalid auth URL: hostname is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.User != nil {
|
||||||
|
return fmt.Errorf("invalid auth URL: embedded credentials are not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if isPrivateIP(host) {
|
||||||
|
return fmt.Errorf("invalid auth URL: private/local network is not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeURLForLog(urlStr string) string {
|
||||||
|
parsed, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return urlStr
|
||||||
|
}
|
||||||
|
if parsed.Host == "" {
|
||||||
|
return parsed.Scheme + "://"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -32,6 +69,13 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
callbackURL = call.Arguments[1].String()
|
callbackURL = call.Arguments[1].String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pendingAuthRequestsMu.Lock()
|
pendingAuthRequestsMu.Lock()
|
||||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
ExtensionID: r.extensionID,
|
ExtensionID: r.extensionID,
|
||||||
@@ -50,7 +94,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
state.AuthCode = ""
|
state.AuthCode = ""
|
||||||
extensionAuthStateMu.Unlock()
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
|
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -273,6 +317,12 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
"error": "authUrl, clientId, and redirectUri are required",
|
"error": "authUrl, clientId, and redirectUri are required",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
scope, _ := config["scope"].(string)
|
scope, _ := config["scope"].(string)
|
||||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||||
@@ -331,7 +381,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
}
|
}
|
||||||
pendingAuthRequestsMu.Unlock()
|
pendingAuthRequestsMu.Unlock()
|
||||||
|
|
||||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
|
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -441,13 +491,17 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
bodyPreview := sanitizeSensitiveLogText(string(body))
|
||||||
|
if len(bodyPreview) > 1000 {
|
||||||
|
bodyPreview = bodyPreview[:1000] + "...[truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
var tokenResp map[string]interface{}
|
var tokenResp map[string]interface{}
|
||||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
||||||
"body": string(body),
|
"body": bodyPreview,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +522,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "no access_token in response",
|
"error": "no access_token in response",
|
||||||
"body": string(body),
|
"body": bodyPreview,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
|||||||
if parsed.Scheme != "https" {
|
if parsed.Scheme != "https" {
|
||||||
return fmt.Errorf("network access denied: only https is allowed")
|
return fmt.Errorf("network access denied: only https is allowed")
|
||||||
}
|
}
|
||||||
|
if parsed.User != nil {
|
||||||
|
return fmt.Errorf("invalid URL: embedded credentials are not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
domain := parsed.Hostname()
|
domain := parsed.Hostname()
|
||||||
if domain == "" {
|
if domain == "" {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(storagePath, data, 0644)
|
return os.WriteFile(storagePath, data, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
}}
|
}}
|
||||||
} else {
|
} else {
|
||||||
|
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,18 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"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 {
|
func sanitizeFilename(filename string) string {
|
||||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||||
@@ -14,7 +22,6 @@ func sanitizeFilename(filename string) string {
|
|||||||
sanitized = strings.TrimSpace(sanitized)
|
sanitized = strings.TrimSpace(sanitized)
|
||||||
sanitized = strings.Trim(sanitized, ".")
|
sanitized = strings.Trim(sanitized, ".")
|
||||||
|
|
||||||
multiUnderscore := regexp.MustCompile(`_+`)
|
|
||||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||||
|
|
||||||
if len(sanitized) > 200 {
|
if len(sanitized) > 200 {
|
||||||
@@ -33,15 +40,25 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
template = "{artist} - {title}"
|
template = "{artist} - {title}"
|
||||||
}
|
}
|
||||||
|
|
||||||
result := template
|
result := replaceFormattedNumberPlaceholders(template, metadata)
|
||||||
|
result = replaceDateFormatPlaceholders(result, metadata)
|
||||||
|
|
||||||
|
dateValue := getDateValue(metadata)
|
||||||
|
yearValue := getString(metadata, "year")
|
||||||
|
if yearValue == "" {
|
||||||
|
yearValue = extractYear(dateValue)
|
||||||
|
}
|
||||||
|
|
||||||
placeholders := map[string]string{
|
placeholders := map[string]string{
|
||||||
"{title}": getString(metadata, "title"),
|
"{title}": getString(metadata, "title"),
|
||||||
"{artist}": getString(metadata, "artist"),
|
"{artist}": getString(metadata, "artist"),
|
||||||
"{album}": getString(metadata, "album"),
|
"{album}": getString(metadata, "album"),
|
||||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||||
"{year}": getString(metadata, "year"),
|
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
||||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
"{year}": yearValue,
|
||||||
|
"{date}": dateValue,
|
||||||
|
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||||
|
"{disc_raw}": formatRawNumber(getInt(metadata, "disc")),
|
||||||
}
|
}
|
||||||
|
|
||||||
for placeholder, value := range placeholders {
|
for placeholder, value := range placeholders {
|
||||||
@@ -51,26 +68,91 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
return result
|
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 {
|
func getString(m map[string]interface{}, key string) string {
|
||||||
if v, ok := m[key]; ok {
|
if v, ok := m[key]; ok {
|
||||||
if s, ok := v.(string); ok {
|
switch value := v.(type) {
|
||||||
return strings.TrimSpace(s)
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func getInt(m map[string]interface{}, key string) int {
|
func getInt(m map[string]interface{}, key string) int {
|
||||||
if v, ok := m[key]; ok {
|
candidateKeys := []string{key}
|
||||||
switch n := v.(type) {
|
switch key {
|
||||||
case int:
|
case "track":
|
||||||
return n
|
candidateKeys = append(candidateKeys, "track_number")
|
||||||
case int64:
|
case "disc":
|
||||||
return int(n)
|
candidateKeys = append(candidateKeys, "disc_number")
|
||||||
case float64:
|
}
|
||||||
return int(n)
|
|
||||||
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +170,129 @@ func formatDiscNumber(n int) string {
|
|||||||
return fmt.Sprintf("%d", n)
|
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 {
|
func extractYear(date string) string {
|
||||||
if len(date) >= 4 {
|
if len(date) >= 4 {
|
||||||
return date[:4]
|
return date[:4]
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"title": "Song Name",
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"album": "Album Name",
|
||||||
|
"track": 1,
|
||||||
|
"disc": 2,
|
||||||
|
"year": "2025",
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := buildFilenameFromTemplate(
|
||||||
|
"{artist} - {track} - {track_raw} - d{disc} - d{disc_raw} - {title}",
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
expected := "Artist Name - 01 - 1 - d2 - d2 - Song Name"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFilenameFromTemplate_RawPlaceholdersEmptyWhenZero(t *testing.T) {
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"title": "Song Name",
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"track": 0,
|
||||||
|
"disc": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := buildFilenameFromTemplate("{track_raw}-{disc_raw}-{title}", metadata)
|
||||||
|
expected := "--Song Name"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"track": 3,
|
||||||
|
"disc": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := buildFilenameFromTemplate("{track:1}-{track:02}-{disc:03}", metadata)
|
||||||
|
expected := "3-03-002"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"title": "Song Name",
|
||||||
|
"release_date": "2024-03-09",
|
||||||
|
"track_number": 7,
|
||||||
|
"disc_number": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := buildFilenameFromTemplate(
|
||||||
|
"{artist} - {track:02} - {title} - {date:%Y-%m-%d} - {year}",
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
expected := "Artist Name - 07 - Song Name - 2024-03-09 - 2024"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing.T) {
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"title": "Song Name",
|
||||||
|
"date": "2019",
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := buildFilenameFromTemplate("{date:%Y}-{date:%m}-{date:%d}", metadata)
|
||||||
|
expected := "2019-01-01"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,16 @@ module github.com/zarz/spotiflac_android/go_backend
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
toolchain go1.25.6
|
toolchain go1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||||
github.com/go-flac/flacpicture v0.3.0
|
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||||
github.com/go-flac/flacvorbis v0.2.0
|
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||||
github.com/go-flac/go-flac v1.0.0
|
github.com/go-flac/go-flac/v2 v2.0.4
|
||||||
github.com/refraction-networking/utls v1.8.2
|
github.com/refraction-networking/utls v1.8.2
|
||||||
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4
|
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af
|
||||||
golang.org/x/net v0.49.0
|
golang.org/x/net v0.50.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -20,10 +20,10 @@ require (
|
|||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||||
github.com/klauspost/compress v1.17.4 // indirect
|
github.com/klauspost/compress v1.17.4 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/mod v0.32.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/tools v0.41.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,16 +2,18 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0
|
|||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||||
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
|
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||||
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
|
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||||
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
|
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
|
||||||
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
|
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
|
||||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
@@ -20,23 +22,43 @@ github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE
|
|||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
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 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 h1:C3JuLOLhdaE75vk5m7u18NvZciRk+lnO34xcXl3NPTU=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4/go.mod h1:yHJY0EGzMJ0i5ONrrhdpDSSnoyres5LO7D2hSIbJJ5I=
|
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 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
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 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
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 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -55,6 +55,27 @@ var sharedTransport = &http.Transport{
|
|||||||
DisableCompression: true,
|
DisableCompression: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
|
||||||
|
// Isolated from download traffic so that download failures cannot poison
|
||||||
|
// the connection pool used by metadata enrichment.
|
||||||
|
var metadataTransport = &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
MaxIdleConns: 30,
|
||||||
|
MaxIdleConnsPerHost: 5,
|
||||||
|
MaxConnsPerHost: 10,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
DisableKeepAlives: false,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
WriteBufferSize: 32 * 1024,
|
||||||
|
ReadBufferSize: 32 * 1024,
|
||||||
|
DisableCompression: true,
|
||||||
|
}
|
||||||
|
|
||||||
var sharedClient = &http.Client{
|
var sharedClient = &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
Timeout: DefaultTimeout,
|
Timeout: DefaultTimeout,
|
||||||
@@ -72,6 +93,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
|
||||||
|
// Use this for API calls that should not be affected by download traffic.
|
||||||
|
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Transport: metadataTransport,
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func GetSharedClient() *http.Client {
|
func GetSharedClient() *http.Client {
|
||||||
return sharedClient
|
return sharedClient
|
||||||
}
|
}
|
||||||
@@ -82,6 +112,7 @@ func GetDownloadClient() *http.Client {
|
|||||||
|
|
||||||
func CloseIdleConnections() {
|
func CloseIdleConnections() {
|
||||||
sharedTransport.CloseIdleConnections()
|
sharedTransport.CloseIdleConnections()
|
||||||
|
metadataTransport.CloseIdleConnections()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also checks for ISP blocking on errors
|
// Also checks for ISP blocking on errors
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type LibraryScanResult struct {
|
|||||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||||
BitDepth int `json:"bitDepth,omitempty"`
|
BitDepth int `json:"bitDepth,omitempty"`
|
||||||
SampleRate int `json:"sampleRate,omitempty"`
|
SampleRate int `json:"sampleRate,omitempty"`
|
||||||
|
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||||
Genre string `json:"genre,omitempty"`
|
Genre string `json:"genre,omitempty"`
|
||||||
Format string `json:"format,omitempty"`
|
Format string `json:"format,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -289,8 +290,11 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
quality, err := GetMP3Quality(filePath)
|
quality, err := GetMP3Quality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth // 0 for lossy
|
||||||
result.Duration = quality.Duration
|
result.Duration = quality.Duration
|
||||||
|
if quality.Bitrate > 0 {
|
||||||
|
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.TrackName == "" {
|
if result.TrackName == "" {
|
||||||
@@ -326,8 +330,11 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
quality, err := GetOggQuality(filePath)
|
quality, err := GetOggQuality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth // 0 for lossy
|
||||||
result.Duration = quality.Duration
|
result.Duration = quality.Duration
|
||||||
|
if quality.Bitrate > 0 {
|
||||||
|
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.TrackName == "" {
|
if result.TrackName == "" {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,22 +23,49 @@ type LogBuffer struct {
|
|||||||
loggingEnabled bool
|
loggingEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultLogBufferSize = 500
|
||||||
|
maxLogMessageLength = 500
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
globalLogBuffer *LogBuffer
|
globalLogBuffer *LogBuffer
|
||||||
logBufferOnce sync.Once
|
logBufferOnce sync.Once
|
||||||
|
|
||||||
|
authorizationBearerPattern = regexp.MustCompile(`(?i)\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||||
|
genericKeyValuePattern = regexp.MustCompile(`(?i)\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)`)
|
||||||
|
queryTokenPattern = regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+`)
|
||||||
|
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func sanitizeSensitiveLogText(message string) string {
|
||||||
|
redacted := message
|
||||||
|
redacted = authorizationBearerPattern.ReplaceAllString(redacted, "Authorization: Bearer [REDACTED]")
|
||||||
|
redacted = genericKeyValuePattern.ReplaceAllString(redacted, `${1}${2}[REDACTED]`)
|
||||||
|
redacted = queryTokenPattern.ReplaceAllString(redacted, `${1}[REDACTED]`)
|
||||||
|
redacted = bearerTokenPattern.ReplaceAllString(redacted, "Bearer [REDACTED]")
|
||||||
|
return redacted
|
||||||
|
}
|
||||||
|
|
||||||
func GetLogBuffer() *LogBuffer {
|
func GetLogBuffer() *LogBuffer {
|
||||||
logBufferOnce.Do(func() {
|
logBufferOnce.Do(func() {
|
||||||
globalLogBuffer = &LogBuffer{
|
globalLogBuffer = &LogBuffer{
|
||||||
entries: make([]LogEntry, 0, 1000),
|
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
||||||
maxSize: 1000,
|
maxSize: defaultLogBufferSize,
|
||||||
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalLogBuffer
|
return globalLogBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func truncateLogMessage(message string) string {
|
||||||
|
runes := []rune(message)
|
||||||
|
if len(runes) <= maxLogMessageLength {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return string(runes[:maxLogMessageLength]) + "...[truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||||
lb.mu.Lock()
|
lb.mu.Lock()
|
||||||
defer lb.mu.Unlock()
|
defer lb.mu.Unlock()
|
||||||
@@ -58,6 +86,9 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message = sanitizeSensitiveLogText(message)
|
||||||
|
message = truncateLogMessage(message)
|
||||||
|
|
||||||
entry := LogEntry{
|
entry := LogEntry{
|
||||||
Timestamp: time.Now().Format("15:04:05.000"),
|
Timestamp: time.Now().Format("15:04:05.000"),
|
||||||
Level: level,
|
Level: level,
|
||||||
|
|||||||
@@ -20,6 +20,140 @@ const (
|
|||||||
durationToleranceSec = 10.0
|
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 {
|
type lyricsCacheEntry struct {
|
||||||
response *LyricsResponse
|
response *LyricsResponse
|
||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
@@ -90,6 +224,15 @@ func (c *lyricsCache) Size() int {
|
|||||||
return len(c.cache)
|
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 {
|
type LRCLibResponse struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -139,7 +282,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
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)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,7 +317,7 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
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)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
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) {
|
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||||
primaryArtist := normalizeArtistName(artistName)
|
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 {
|
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||||
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
isExtensionCache := strings.HasPrefix(cached.Source, "Extension:")
|
||||||
cachedCopy := *cached
|
if len(extensionProviders) == 0 || isExtensionCache {
|
||||||
cachedCopy.Source = cached.Source + " (cached)"
|
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
|
return &cachedCopy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var lyrics *LyricsResponse
|
// Get configured provider order
|
||||||
var err error
|
providerOrder := GetLyricsProviderOrder()
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
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) {
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB (simplified)"
|
GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
query := primaryArtist + " " + trackName
|
if err != nil {
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("lyrics not found from any source")
|
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 {
|
func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse {
|
||||||
result := &LyricsResponse{
|
result := &LyricsResponse{
|
||||||
Instrumental: resp.Instrumental,
|
Instrumental: resp.Instrumental,
|
||||||
@@ -339,10 +617,20 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
|
|||||||
continue
|
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)
|
matches := lrcPattern.FindStringSubmatch(line)
|
||||||
if len(matches) == 5 {
|
if len(matches) == 5 {
|
||||||
startMs := lrcTimestampToMs(matches[1], matches[2], matches[3])
|
startMs := lrcTimestampToMs(matches[1], matches[2], matches[3])
|
||||||
words := strings.TrimSpace(matches[4])
|
words := strings.TrimSpace(matches[4])
|
||||||
|
if words == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
lines = append(lines, LyricsLine{
|
lines = append(lines, LyricsLine{
|
||||||
StartTimeMs: startMs,
|
StartTimeMs: startMs,
|
||||||
@@ -363,6 +651,63 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
|
|||||||
return lines
|
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 {
|
func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
|
||||||
min, _ := strconv.ParseInt(minutes, 10, 64)
|
min, _ := strconv.ParseInt(minutes, 10, 64)
|
||||||
sec, _ := strconv.ParseInt(seconds, 10, 64)
|
sec, _ := strconv.ParseInt(seconds, 10, 64)
|
||||||
@@ -376,12 +721,16 @@ func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func msToLRCTimestamp(ms int64) string {
|
func msToLRCTimestamp(ms int64) string {
|
||||||
|
return fmt.Sprintf("[%s]", msToLRCTimestampInline(ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
func msToLRCTimestampInline(ms int64) string {
|
||||||
totalSeconds := ms / 1000
|
totalSeconds := ms / 1000
|
||||||
minutes := totalSeconds / 60
|
minutes := totalSeconds / 60
|
||||||
seconds := totalSeconds % 60
|
seconds := totalSeconds % 60
|
||||||
centiseconds := (ms % 1000) / 10
|
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 {
|
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppleMusicClient fetches lyrics from Apple Music.
|
||||||
|
// Uses a scraped JWT token for search and a proxy for lyrics.
|
||||||
|
type AppleMusicClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple Music token manager — singleton with mutex for thread safety
|
||||||
|
type appleTokenManager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalAppleTokenManager = &appleTokenManager{}
|
||||||
|
|
||||||
|
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if m.token != "" {
|
||||||
|
return m.token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Fetch the Apple Music beta page
|
||||||
|
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Find the index JS file URL
|
||||||
|
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
|
||||||
|
match := indexJsRegex.Find(body)
|
||||||
|
if match == nil {
|
||||||
|
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
|
||||||
|
}
|
||||||
|
|
||||||
|
indexJsURL := "https://beta.music.apple.com" + string(match)
|
||||||
|
|
||||||
|
// Step 3: Fetch the JS file
|
||||||
|
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create JS request: %w", err)
|
||||||
|
}
|
||||||
|
jsReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
jsResp, err := client.Do(jsReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
|
||||||
|
}
|
||||||
|
defer jsResp.Body.Close()
|
||||||
|
|
||||||
|
jsBody, err := io.ReadAll(jsResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Extract JWT token (starts with eyJh)
|
||||||
|
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
|
||||||
|
tokenMatch := tokenRegex.Find(jsBody)
|
||||||
|
if tokenMatch == nil {
|
||||||
|
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.token = string(tokenMatch)
|
||||||
|
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
|
||||||
|
return m.token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *appleTokenManager) clearToken() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.token = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple Music API response models
|
||||||
|
type appleMusicSearchResponse struct {
|
||||||
|
Results struct {
|
||||||
|
Songs *struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"songs"`
|
||||||
|
} `json:"results"`
|
||||||
|
Resources *struct {
|
||||||
|
Songs map[string]struct {
|
||||||
|
Attributes struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
AlbumName string `json:"albumName"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Artwork struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"artwork"`
|
||||||
|
} `json:"attributes"`
|
||||||
|
} `json:"songs"`
|
||||||
|
} `json:"resources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
||||||
|
type paxResponse struct {
|
||||||
|
Type string `json:"type"` // "Syllable" or "Line"
|
||||||
|
Content []paxLyrics `json:"content"` // List of lyric lines
|
||||||
|
}
|
||||||
|
|
||||||
|
type paxLyrics struct {
|
||||||
|
Text []paxLyricDetail `json:"text"`
|
||||||
|
Timestamp int `json:"timestamp"`
|
||||||
|
OppositeTurn bool `json:"oppositeTurn"`
|
||||||
|
Background bool `json:"background"`
|
||||||
|
BackgroundText []paxLyricDetail `json:"backgroundText"`
|
||||||
|
EndTime int `json:"endtime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type paxLyricDetail struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Part bool `json:"part"`
|
||||||
|
Timestamp *int `json:"timestamp"`
|
||||||
|
EndTime *int `json:"endtime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppleMusicClient() *AppleMusicClient {
|
||||||
|
return &AppleMusicClient{
|
||||||
|
httpClient: NewMetadataHTTPClient(20 * time.Second),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchSong searches for a song on Apple Music and returns its ID.
|
||||||
|
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
|
||||||
|
query := trackName + " " + artistName
|
||||||
|
if strings.TrimSpace(query) == "" {
|
||||||
|
return "", fmt.Errorf("empty search query")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := globalAppleTokenManager.getToken(c.httpClient)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("apple music token error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encodedQuery := url.QueryEscape(query)
|
||||||
|
searchURL := fmt.Sprintf(
|
||||||
|
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
|
||||||
|
encodedQuery,
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Origin", "https://music.apple.com")
|
||||||
|
req.Header.Set("Referer", "https://music.apple.com/")
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("apple music search failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 401 {
|
||||||
|
globalAppleTokenManager.clearToken()
|
||||||
|
return "", fmt.Errorf("apple music token expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp appleMusicSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
|
||||||
|
return "", fmt.Errorf("no songs found on apple music")
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchResp.Results.Songs.Data[0].ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
|
||||||
|
func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||||
|
lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", lyricsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("apple music lyrics fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("apple music lyrics proxy returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read lyrics response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStr := strings.TrimSpace(string(bodyBytes))
|
||||||
|
if bodyStr == "" {
|
||||||
|
return "", fmt.Errorf("empty lyrics response from apple music")
|
||||||
|
}
|
||||||
|
|
||||||
|
return bodyStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPaxLyricsToLRC converts a pax proxy response to standard LRC format.
|
||||||
|
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||||
|
// Try to parse as PaxResponse first
|
||||||
|
var paxResp paxResponse
|
||||||
|
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||||
|
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as a direct list of PaxLyrics
|
||||||
|
var directLyrics []paxLyrics
|
||||||
|
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
||||||
|
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("failed to parse pax lyrics response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
|
||||||
|
lastStart := ""
|
||||||
|
|
||||||
|
for _, syllable := range details {
|
||||||
|
if syllable.Timestamp != nil {
|
||||||
|
start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp)))
|
||||||
|
if start != lastStart {
|
||||||
|
builder.WriteString(start)
|
||||||
|
lastStart = start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.WriteString(syllable.Text)
|
||||||
|
if !syllable.Part {
|
||||||
|
builder.WriteString(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
if syllable.EndTime != nil {
|
||||||
|
builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
for i, line := range content {
|
||||||
|
if i > 0 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := msToLRCTimestamp(int64(line.Timestamp))
|
||||||
|
|
||||||
|
if strings.EqualFold(lyricsType, "Syllable") {
|
||||||
|
sb.WriteString(timestamp)
|
||||||
|
if multiPersonWordByWord {
|
||||||
|
if line.OppositeTurn {
|
||||||
|
sb.WriteString("v2:")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("v1:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendPaxLyricDetail(&sb, line.Text)
|
||||||
|
|
||||||
|
if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 {
|
||||||
|
sb.WriteString("\n[bg:")
|
||||||
|
appendPaxLyricDetail(&sb, line.BackgroundText)
|
||||||
|
sb.WriteString("]")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(line.Text) > 0 {
|
||||||
|
sb.WriteString(timestamp)
|
||||||
|
sb.WriteString(line.Text[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyrics searches Apple Music and returns parsed LyricsResponse.
|
||||||
|
func (c *AppleMusicClient) FetchLyrics(
|
||||||
|
trackName,
|
||||||
|
artistName string,
|
||||||
|
durationSec float64,
|
||||||
|
multiPersonWordByWord bool,
|
||||||
|
) (*LyricsResponse, error) {
|
||||||
|
songID, err := c.SearchSong(trackName, artistName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rawLyrics, err := c.FetchLyricsByID(songID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
|
||||||
|
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as pax format (word-by-word or line)
|
||||||
|
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||||
|
if err != nil {
|
||||||
|
// If pax parsing fails, try to parse as direct LRC text
|
||||||
|
lrcText = rawLyrics
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := parseSyncedLyrics(lrcText)
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
Provider: "Apple Music",
|
||||||
|
Source: "Apple Music",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to plain text if no timestamps found
|
||||||
|
plainLines := strings.Split(lrcText, "\n")
|
||||||
|
var resultLines []LyricsLine
|
||||||
|
for _, line := range plainLines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed != "" {
|
||||||
|
resultLines = append(resultLines, LyricsLine{
|
||||||
|
StartTimeMs: 0,
|
||||||
|
Words: trimmed,
|
||||||
|
EndTimeMs: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resultLines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: resultLines,
|
||||||
|
SyncType: "UNSYNCED",
|
||||||
|
Provider: "Apple Music",
|
||||||
|
Source: "Apple Music",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no lyrics found on apple music")
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MusixmatchClient fetches lyrics from Musixmatch via a proxy server.
|
||||||
|
// The proxy handles Musixmatch authentication internally.
|
||||||
|
type MusixmatchClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Musixmatch proxy response models
|
||||||
|
type musixmatchSearchResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
SongName string `json:"songName"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
AlbumName string `json:"albumName"`
|
||||||
|
Artwork string `json:"artwork"`
|
||||||
|
ReleaseDate string `json:"releaseDate"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
AlbumID int64 `json:"albumId"`
|
||||||
|
HasSyncedLyrics bool `json:"hasSyncedLyrics"`
|
||||||
|
HasUnsyncedLyrics bool `json:"hasUnsyncedLyrics"`
|
||||||
|
AvailableLanguages []string `json:"availableLanguages"`
|
||||||
|
OriginalLanguage string `json:"originalLanguage"`
|
||||||
|
SyncedLyrics *musixmatchLyricsResponse `json:"syncedLyrics"`
|
||||||
|
UnsyncedLyrics *musixmatchLyricsResponse `json:"unsyncedLyrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type musixmatchLyricsResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
UpdatedTime string `json:"updatedTime"`
|
||||||
|
Lyrics string `json:"lyrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMusixmatchClient() *MusixmatchClient {
|
||||||
|
return &MusixmatchClient{
|
||||||
|
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||||
|
baseURL: "http://158.180.60.95",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
|
||||||
|
// The Musixmatch proxy returns both search result and lyrics in a single response.
|
||||||
|
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
|
||||||
|
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||||
|
return nil, fmt.Errorf("empty track or artist name")
|
||||||
|
}
|
||||||
|
|
||||||
|
encodedArtist := url.QueryEscape(artistName)
|
||||||
|
encodedTrack := url.QueryEscape(trackName)
|
||||||
|
|
||||||
|
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("musixmatch search failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result musixmatchSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
||||||
|
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
|
||||||
|
lang := strings.ToLower(strings.TrimSpace(language))
|
||||||
|
if songID <= 0 || lang == "" {
|
||||||
|
return nil, fmt.Errorf("invalid song id or language")
|
||||||
|
}
|
||||||
|
|
||||||
|
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result musixmatchSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer synced lyrics for selected language
|
||||||
|
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||||
|
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
Provider: "Musixmatch",
|
||||||
|
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to unsynced lyrics for selected language
|
||||||
|
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||||
|
var lines []LyricsLine
|
||||||
|
for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed != "" {
|
||||||
|
lines = append(lines, LyricsLine{
|
||||||
|
StartTimeMs: 0,
|
||||||
|
Words: trimmed,
|
||||||
|
EndTimeMs: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "UNSYNCED",
|
||||||
|
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||||
|
Provider: "Musixmatch",
|
||||||
|
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
||||||
|
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||||
|
result, err := c.searchAndGetLyrics(trackName, artistName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
||||||
|
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
||||||
|
if localizedErr == nil {
|
||||||
|
return localized, nil
|
||||||
|
}
|
||||||
|
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer synced lyrics
|
||||||
|
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||||
|
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
Provider: "Musixmatch",
|
||||||
|
Source: "Musixmatch",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to unsynced lyrics
|
||||||
|
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||||
|
var lines []LyricsLine
|
||||||
|
for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed != "" {
|
||||||
|
lines = append(lines, LyricsLine{
|
||||||
|
StartTimeMs: 0,
|
||||||
|
Words: trimmed,
|
||||||
|
EndTimeMs: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "UNSYNCED",
|
||||||
|
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||||
|
Provider: "Musixmatch",
|
||||||
|
Source: "Musixmatch",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
|
||||||
|
// This is a direct public API — no proxy dependency.
|
||||||
|
type NeteaseClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Netease API response models
|
||||||
|
type neteaseSearchResponse struct {
|
||||||
|
Result struct {
|
||||||
|
Songs []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Artists []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"artists"`
|
||||||
|
} `json:"songs"`
|
||||||
|
SongCount int `json:"songCount"`
|
||||||
|
} `json:"result"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type neteaseLyricsResponse struct {
|
||||||
|
LRC *neteaseLyricField `json:"lrc"`
|
||||||
|
TLyric *neteaseLyricField `json:"tlyric"`
|
||||||
|
RomaLRC *neteaseLyricField `json:"romalrc"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type neteaseLyricField struct {
|
||||||
|
Lyric string `json:"lyric"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var neteaseHeaders = map[string]string{
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
"Cache-Control": "max-age=0",
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNeteaseClient() *NeteaseClient {
|
||||||
|
return &NeteaseClient{
|
||||||
|
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchSong searches for a song on Netease and returns the song ID.
|
||||||
|
func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) {
|
||||||
|
query := trackName + " " + artistName
|
||||||
|
if strings.TrimSpace(query) == "" {
|
||||||
|
return 0, fmt.Errorf("empty search query")
|
||||||
|
}
|
||||||
|
|
||||||
|
searchURL := "http://music.163.com/api/search/pc"
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("s", query)
|
||||||
|
params.Set("type", "1")
|
||||||
|
params.Set("limit", "1")
|
||||||
|
params.Set("offset", "0")
|
||||||
|
|
||||||
|
fullURL := searchURL + "?" + params.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range neteaseHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("netease search failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return 0, fmt.Errorf("netease search returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp neteaseSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to decode netease search: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 {
|
||||||
|
return 0, fmt.Errorf("no songs found on netease")
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchResp.Result.Songs[0].ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
|
||||||
|
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
||||||
|
lyricsURL := "http://music.163.com/api/song/lyric"
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("id", fmt.Sprintf("%d", songID))
|
||||||
|
params.Set("lv", "1")
|
||||||
|
params.Set("tv", "1")
|
||||||
|
params.Set("rv", "1")
|
||||||
|
|
||||||
|
fullURL := lyricsURL + "?" + params.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range neteaseHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("netease lyrics fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("netease lyrics returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lyricsResp neteaseLyricsResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&lyricsResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode netease lyrics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lyricsResp.LRC == nil || strings.TrimSpace(lyricsResp.LRC.Lyric) == "" {
|
||||||
|
return "", fmt.Errorf("no lyrics available on netease")
|
||||||
|
}
|
||||||
|
|
||||||
|
lyric := lyricsResp.LRC.Lyric
|
||||||
|
|
||||||
|
if includeTranslation && lyricsResp.TLyric != nil && strings.TrimSpace(lyricsResp.TLyric.Lyric) != "" {
|
||||||
|
lyric += "\n\n" + lyricsResp.TLyric.Lyric
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeRomanization && lyricsResp.RomaLRC != nil && strings.TrimSpace(lyricsResp.RomaLRC.Lyric) != "" {
|
||||||
|
lyric += "\n\n" + lyricsResp.RomaLRC.Lyric
|
||||||
|
}
|
||||||
|
|
||||||
|
return lyric, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyrics searches for a track and returns parsed LyricsResponse.
|
||||||
|
func (c *NeteaseClient) FetchLyrics(
|
||||||
|
trackName,
|
||||||
|
artistName string,
|
||||||
|
durationSec float64,
|
||||||
|
includeTranslation,
|
||||||
|
includeRomanization bool,
|
||||||
|
) (*LyricsResponse, error) {
|
||||||
|
songID, err := c.SearchSong(trackName, artistName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lrcText, err := c.FetchLyricsByID(songID, includeTranslation, includeRomanization)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the LRC text into LyricsResponse
|
||||||
|
lines := parseSyncedLyrics(lrcText)
|
||||||
|
if len(lines) == 0 {
|
||||||
|
// May be plain text lyrics without timestamps
|
||||||
|
plainLines := strings.Split(lrcText, "\n")
|
||||||
|
for _, line := range plainLines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed != "" {
|
||||||
|
lines = append(lines, LyricsLine{
|
||||||
|
StartTimeMs: 0,
|
||||||
|
Words: trimmed,
|
||||||
|
EndTimeMs: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return nil, fmt.Errorf("netease returned empty lyrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "UNSYNCED",
|
||||||
|
Provider: "Netease",
|
||||||
|
Source: "Netease",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
Provider: "Netease",
|
||||||
|
Source: "Netease",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QQMusicClient fetches lyrics from QQ Music.
|
||||||
|
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
|
||||||
|
type QQMusicClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// QQ Music search response models
|
||||||
|
type qqMusicSearchResponse struct {
|
||||||
|
Data struct {
|
||||||
|
Song struct {
|
||||||
|
List []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Singer []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"singer"`
|
||||||
|
Album struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"album"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"list"`
|
||||||
|
} `json:"song"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QQ Music lyrics request payload for paxsenix proxy
|
||||||
|
type qqLyricsPayload struct {
|
||||||
|
Artist []string `json:"artist"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQQMusicClient() *QQMusicClient {
|
||||||
|
return &QQMusicClient{
|
||||||
|
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
||||||
|
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
||||||
|
query := trackName + " " + artistName
|
||||||
|
if strings.TrimSpace(query) == "" {
|
||||||
|
return nil, fmt.Errorf("empty search query")
|
||||||
|
}
|
||||||
|
|
||||||
|
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("format", "json")
|
||||||
|
params.Set("inCharset", "utf8")
|
||||||
|
params.Set("outCharset", "utf8")
|
||||||
|
params.Set("platform", "yqq.json")
|
||||||
|
params.Set("new_json", "1")
|
||||||
|
params.Set("w", query)
|
||||||
|
|
||||||
|
fullURL := searchURL + "?" + params.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("qqmusic search failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp qqMusicSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResp.Data.Song.List) == 0 {
|
||||||
|
return nil, fmt.Errorf("no songs found on qqmusic")
|
||||||
|
}
|
||||||
|
|
||||||
|
song := searchResp.Data.Song.List[0]
|
||||||
|
|
||||||
|
var artists []string
|
||||||
|
for _, singer := range song.Singer {
|
||||||
|
artists = append(artists, singer.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &qqLyricsPayload{
|
||||||
|
Artist: artists,
|
||||||
|
Album: song.Album.Name,
|
||||||
|
ID: song.ID,
|
||||||
|
Title: song.Title,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
|
||||||
|
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
|
||||||
|
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
|
||||||
|
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("qqmusic lyrics fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("qqmusic lyrics proxy returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read lyrics response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStr := strings.TrimSpace(string(bodyBytes))
|
||||||
|
if bodyStr == "" {
|
||||||
|
return "", fmt.Errorf("empty lyrics response from qqmusic")
|
||||||
|
}
|
||||||
|
|
||||||
|
return bodyStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||||
|
func (c *QQMusicClient) FetchLyrics(
|
||||||
|
trackName,
|
||||||
|
artistName string,
|
||||||
|
durationSec float64,
|
||||||
|
multiPersonWordByWord bool,
|
||||||
|
) (*LyricsResponse, error) {
|
||||||
|
payload, err := c.searchSong(trackName, artistName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
|
||||||
|
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as pax format (word-by-word or line)
|
||||||
|
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||||
|
if err != nil {
|
||||||
|
// If pax parsing fails, try to use as direct LRC text
|
||||||
|
lrcText = rawLyrics
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := parseSyncedLyrics(lrcText)
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
Provider: "QQ Music",
|
||||||
|
Source: "QQ Music",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to plain text
|
||||||
|
plainLines := strings.Split(lrcText, "\n")
|
||||||
|
var resultLines []LyricsLine
|
||||||
|
for _, line := range plainLines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed != "" {
|
||||||
|
resultLines = append(resultLines, LyricsLine{
|
||||||
|
StartTimeMs: 0,
|
||||||
|
Words: trimmed,
|
||||||
|
EndTimeMs: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resultLines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: resultLines,
|
||||||
|
SyncType: "UNSYNCED",
|
||||||
|
Provider: "QQ Music",
|
||||||
|
Source: "QQ Music",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no lyrics found on qqmusic")
|
||||||
|
}
|
||||||
@@ -4,16 +4,97 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
stdimage "image"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture/v2"
|
||||||
"github.com/go-flac/flacvorbis"
|
"github.com/go-flac/flacvorbis/v2"
|
||||||
"github.com/go-flac/go-flac"
|
"github.com/go-flac/go-flac/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func detectCoverMIME(coverPath string, coverData []byte) string {
|
||||||
|
// Prefer magic-byte detection over file extension.
|
||||||
|
// Some providers return non-JPEG data behind .jpg URLs.
|
||||||
|
if len(coverData) >= 8 &&
|
||||||
|
coverData[0] == 0x89 &&
|
||||||
|
coverData[1] == 0x50 &&
|
||||||
|
coverData[2] == 0x4E &&
|
||||||
|
coverData[3] == 0x47 &&
|
||||||
|
coverData[4] == 0x0D &&
|
||||||
|
coverData[5] == 0x0A &&
|
||||||
|
coverData[6] == 0x1A &&
|
||||||
|
coverData[7] == 0x0A {
|
||||||
|
return "image/png"
|
||||||
|
}
|
||||||
|
if len(coverData) >= 3 &&
|
||||||
|
coverData[0] == 0xFF &&
|
||||||
|
coverData[1] == 0xD8 &&
|
||||||
|
coverData[2] == 0xFF {
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
if len(coverData) >= 6 {
|
||||||
|
header := string(coverData[:6])
|
||||||
|
if header == "GIF87a" || header == "GIF89a" {
|
||||||
|
return "image/gif"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(coverData) >= 12 &&
|
||||||
|
string(coverData[:4]) == "RIFF" &&
|
||||||
|
string(coverData[8:12]) == "WEBP" {
|
||||||
|
return "image/webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(filepath.Ext(strings.TrimSpace(coverPath))) {
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
|
||||||
|
if len(coverData) == 0 {
|
||||||
|
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
|
||||||
|
}
|
||||||
|
|
||||||
|
mime := detectCoverMIME(coverPath, coverData)
|
||||||
|
picture := &flacpicture.MetadataBlockPicture{
|
||||||
|
PictureType: flacpicture.PictureTypeFrontCover,
|
||||||
|
MIME: mime,
|
||||||
|
Description: "Front Cover",
|
||||||
|
ImageData: coverData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width/height/depth are optional in practice; keep zero when decode fails.
|
||||||
|
if cfg, format, err := stdimage.DecodeConfig(bytes.NewReader(coverData)); err == nil {
|
||||||
|
picture.Width = uint32(cfg.Width)
|
||||||
|
picture.Height = uint32(cfg.Height)
|
||||||
|
switch format {
|
||||||
|
case "png":
|
||||||
|
picture.ColorDepth = 32
|
||||||
|
case "jpeg":
|
||||||
|
picture.ColorDepth = 24
|
||||||
|
default:
|
||||||
|
picture.ColorDepth = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return picture.Marshal(), nil
|
||||||
|
}
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
@@ -29,6 +110,8 @@ type Metadata struct {
|
|||||||
Genre string
|
Genre string
|
||||||
Label string
|
Label string
|
||||||
Copyright string
|
Copyright string
|
||||||
|
Composer string
|
||||||
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||||
@@ -98,6 +181,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if metadata.Composer != "" {
|
||||||
|
setComment(cmt, "COMPOSER", metadata.Composer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Comment != "" {
|
||||||
|
setComment(cmt, "COMMENT", metadata.Comment)
|
||||||
|
}
|
||||||
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx >= 0 {
|
if cmtIdx >= 0 {
|
||||||
f.Meta[cmtIdx] = &cmtBlock
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
@@ -117,19 +208,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picBlock, err := buildPictureBlock(coverPath, coverData)
|
||||||
flacpicture.PictureTypeFrontCover,
|
|
||||||
"Front Cover",
|
|
||||||
coverData,
|
|
||||||
"image/jpeg",
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
return fmt.Errorf("failed to create picture block: %w", err)
|
||||||
} else {
|
|
||||||
picBlock := picture.Marshal()
|
|
||||||
f.Meta = append(f.Meta, &picBlock)
|
|
||||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
||||||
}
|
}
|
||||||
|
f.Meta = append(f.Meta, &picBlock)
|
||||||
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
||||||
@@ -206,6 +290,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if metadata.Composer != "" {
|
||||||
|
setComment(cmt, "COMPOSER", metadata.Composer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Comment != "" {
|
||||||
|
setComment(cmt, "COMMENT", metadata.Comment)
|
||||||
|
}
|
||||||
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx >= 0 {
|
if cmtIdx >= 0 {
|
||||||
f.Meta[cmtIdx] = &cmtBlock
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
@@ -220,19 +312,12 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picBlock, err := buildPictureBlock("", coverData)
|
||||||
flacpicture.PictureTypeFrontCover,
|
|
||||||
"Front Cover",
|
|
||||||
coverData,
|
|
||||||
"image/jpeg",
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
return fmt.Errorf("failed to create picture block: %w", err)
|
||||||
} else {
|
|
||||||
picBlock := picture.Marshal()
|
|
||||||
f.Meta = append(f.Meta, &picBlock)
|
|
||||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
||||||
}
|
}
|
||||||
|
f.Meta = append(f.Meta, &picBlock)
|
||||||
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
@@ -292,6 +377,12 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
metadata.Date = getComment(cmt, "YEAR")
|
metadata.Date = getComment(cmt, "YEAR")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metadata.Genre = getComment(cmt, "GENRE")
|
||||||
|
metadata.Label = getComment(cmt, "ORGANIZATION")
|
||||||
|
metadata.Copyright = getComment(cmt, "COPYRIGHT")
|
||||||
|
metadata.Composer = getComment(cmt, "COMPOSER")
|
||||||
|
metadata.Comment = getComment(cmt, "COMMENT")
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,33 +542,91 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExtractLyrics(filePath string) (string, error) {
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
|
lower := strings.ToLower(filePath)
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".flac") {
|
||||||
|
return extractLyricsFromFlac(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".mp3") {
|
||||||
|
meta, err := ReadID3Tags(filePath)
|
||||||
|
if err != nil || meta == nil {
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||||
|
return meta.Lyrics, nil
|
||||||
|
}
|
||||||
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||||
|
return meta.Comment, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||||
|
meta, err := ReadOggVorbisComments(filePath)
|
||||||
|
if err != nil || meta == nil {
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||||
|
return meta.Lyrics, nil
|
||||||
|
}
|
||||||
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||||
|
return meta.Comment, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("unsupported file format for lyrics extraction")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLyricsFromFlac(filePath string) (string, error) {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, meta := range f.Meta {
|
for _, meta := range f.Meta {
|
||||||
if meta.Type == flac.VorbisComment {
|
if meta.Type != flac.VorbisComment {
|
||||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
continue
|
||||||
if err != nil {
|
}
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lyrics, err := cmt.Get("LYRICS")
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err != nil {
|
||||||
return lyrics[0], nil
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
|
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||||
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("no lyrics found in file")
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func looksLikeEmbeddedLyrics(value string) bool {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if strings.Contains(lower, "[ar:") || strings.Contains(lower, "[ti:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(trimmed, "\n") && strings.Contains(trimmed, "[") && strings.Contains(trimmed, "]") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type AudioQuality struct {
|
type AudioQuality struct {
|
||||||
BitDepth int `json:"bit_depth"`
|
BitDepth int `json:"bit_depth"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
|
|||||||
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
||||||
formatID := mapJumoQuality(quality)
|
formatID := mapJumoQuality(quality)
|
||||||
region := "US"
|
region := "US"
|
||||||
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
||||||
|
|
||||||
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
||||||
|
|
||||||
@@ -428,6 +428,8 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1178,6 +1180,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
"track": req.TrackNumber,
|
"track": req.TrackNumber,
|
||||||
"year": extractYear(req.ReleaseDate),
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
"disc": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
})
|
})
|
||||||
var outputPath string
|
var outputPath string
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeSensitiveLogText(t *testing.T) {
|
||||||
|
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
|
||||||
|
redacted := sanitizeSensitiveLogText(input)
|
||||||
|
|
||||||
|
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
|
||||||
|
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
|
||||||
|
}
|
||||||
|
if !strings.Contains(redacted, "[REDACTED]") {
|
||||||
|
t.Fatalf("expected redaction marker in output, got: %s", redacted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateExtensionAuthURL(t *testing.T) {
|
||||||
|
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
|
||||||
|
t.Fatalf("expected valid auth URL, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blocked := []string{
|
||||||
|
"http://accounts.example.com/oauth/authorize",
|
||||||
|
"https://user:pass@accounts.example.com/oauth/authorize",
|
||||||
|
"https://localhost/oauth/authorize",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rawURL := range blocked {
|
||||||
|
if err := validateExtensionAuthURL(rawURL); err == nil {
|
||||||
|
t.Fatalf("expected URL to be blocked: %s", rawURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
Network: []string{"api.example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
|
||||||
|
t.Fatal("expected embedded URL credentials to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildStoreExtensionDestPath(t *testing.T) {
|
||||||
|
baseDir := t.TempDir()
|
||||||
|
|
||||||
|
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isPathWithinBase(baseDir, destPath) {
|
||||||
|
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseName := filepath.Base(destPath)
|
||||||
|
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
|
||||||
|
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
|
||||||
|
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
|
||||||
|
t.Fatal("expected empty extension id to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,18 +15,21 @@ type SongLinkClient struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TrackAvailability struct {
|
type TrackAvailability struct {
|
||||||
SpotifyID string `json:"spotify_id"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
Amazon bool `json:"amazon"`
|
Amazon bool `json:"amazon"`
|
||||||
Qobuz bool `json:"qobuz"`
|
Qobuz bool `json:"qobuz"`
|
||||||
Deezer bool `json:"deezer"`
|
Deezer bool `json:"deezer"`
|
||||||
TidalURL string `json:"tidal_url,omitempty"`
|
YouTube bool `json:"youtube"`
|
||||||
AmazonURL string `json:"amazon_url,omitempty"`
|
TidalURL string `json:"tidal_url,omitempty"`
|
||||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
AmazonURL string `json:"amazon_url,omitempty"`
|
||||||
DeezerURL string `json:"deezer_url,omitempty"`
|
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||||
DeezerID string `json:"deezer_id,omitempty"`
|
DeezerURL string `json:"deezer_url,omitempty"`
|
||||||
QobuzID string `json:"qobuz_id,omitempty"`
|
YouTubeURL string `json:"youtube_url,omitempty"`
|
||||||
TidalID string `json:"tidal_id,omitempty"`
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
|
QobuzID string `json:"qobuz_id,omitempty"`
|
||||||
|
TidalID string `json:"tidal_id,omitempty"`
|
||||||
|
YouTubeID string `json:"youtube_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -37,7 +40,7 @@ var (
|
|||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
songLinkClientOnce.Do(func() {
|
songLinkClientOnce.Do(func() {
|
||||||
globalSongLinkClient = &SongLinkClient{
|
globalSongLinkClient = &SongLinkClient{
|
||||||
client: NewHTTPClientWithTimeout(SongLinkTimeout),
|
client: NewMetadataHTTPClient(SongLinkTimeout),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalSongLinkClient
|
return globalSongLinkClient
|
||||||
@@ -119,6 +122,22 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
|
||||||
|
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = ytMusicLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to regular youtube if youtubeMusic not available
|
||||||
|
if !availability.YouTube {
|
||||||
|
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = youtubeLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +265,52 @@ func extractTidalIDFromURL(tidalURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractYouTubeIDFromURL extracts YouTube video ID from URL
|
||||||
|
// URL formats:
|
||||||
|
// - https://www.youtube.com/watch?v=VIDEO_ID
|
||||||
|
// - https://youtu.be/VIDEO_ID
|
||||||
|
// - https://music.youtube.com/watch?v=VIDEO_ID
|
||||||
|
func extractYouTubeIDFromURL(youtubeURL string) string {
|
||||||
|
if youtubeURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle youtu.be short URLs
|
||||||
|
if strings.Contains(youtubeURL, "youtu.be/") {
|
||||||
|
parts := strings.Split(youtubeURL, "youtu.be/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
idPart := parts[1]
|
||||||
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
if idx := strings.Index(idPart, "&"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(idPart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle youtube.com URLs with ?v= parameter
|
||||||
|
parsed, err := url.Parse(youtubeURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := parsed.Query().Get("v"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle /embed/ format
|
||||||
|
if strings.Contains(parsed.Path, "/embed/") {
|
||||||
|
parts := strings.Split(parsed.Path, "/embed/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return strings.Split(parts[1], "/")[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// isNumeric is defined in library_scan.go
|
// isNumeric is defined in library_scan.go
|
||||||
|
|
||||||
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
@@ -261,6 +326,20 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
|
|||||||
return availability.DeezerID, nil
|
return availability.DeezerID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.YouTube || availability.YouTubeURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on YouTube")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.YouTubeURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AlbumAvailability represents album availability on different platforms
|
// AlbumAvailability represents album availability on different platforms
|
||||||
type AlbumAvailability struct {
|
type AlbumAvailability struct {
|
||||||
SpotifyID string `json:"spotify_id"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
@@ -441,6 +520,19 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
|||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = youtubeLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||||
|
}
|
||||||
|
if !availability.YouTube {
|
||||||
|
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = ytMusicLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +620,19 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = youtubeLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||||
|
}
|
||||||
|
if !availability.YouTube {
|
||||||
|
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = ytMusicLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +689,20 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
|
|||||||
return availability.AmazonURL, nil
|
return availability.AmazonURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.YouTube || availability.YouTubeURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on YouTube")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.YouTubeURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
@@ -652,6 +771,18 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
|
|||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
}
|
}
|
||||||
|
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = youtubeLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||||
|
}
|
||||||
|
if !availability.YouTube {
|
||||||
|
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = ytMusicLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
||||||
|
|
||||||
|
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
||||||
|
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
||||||
|
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
|
||||||
|
parsed, err := parseSpotifyURI(spotifyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := strings.TrimSpace(apiBaseURL)
|
||||||
|
if base == "" {
|
||||||
|
base = DefaultSpotFetchAPIBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parsed.Type {
|
||||||
|
case "track":
|
||||||
|
var trackResp TrackResponse
|
||||||
|
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
||||||
|
}
|
||||||
|
return trackResp, nil
|
||||||
|
case "album":
|
||||||
|
var albumResp AlbumResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
||||||
|
}
|
||||||
|
return &albumResp, nil
|
||||||
|
case "playlist":
|
||||||
|
var playlistResp PlaylistResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
||||||
|
}
|
||||||
|
return playlistResp, nil
|
||||||
|
case "artist":
|
||||||
|
var artistResp ArtistResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
||||||
|
}
|
||||||
|
return &artistResp, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,7 +114,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
|||||||
src := rand.NewSource(time.Now().UnixNano())
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
|
|
||||||
c := &SpotifyMetadataClient{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
rng: rand.New(src),
|
rng: rand.New(src),
|
||||||
|
|||||||
@@ -1609,6 +1609,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
"track": req.TrackNumber,
|
"track": req.TrackNumber,
|
||||||
"year": extractYear(req.ReleaseDate),
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
"disc": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type YouTubeDownloader struct {
|
||||||
|
client *http.Client
|
||||||
|
apiURL string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalYouTubeDownloader *YouTubeDownloader
|
||||||
|
youtubeDownloaderOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
type YouTubeQuality string
|
||||||
|
|
||||||
|
const (
|
||||||
|
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||||
|
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CobaltRequest struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
AudioBitrate string `json:"audioBitrate,omitempty"`
|
||||||
|
AudioFormat string `json:"audioFormat,omitempty"`
|
||||||
|
DownloadMode string `json:"downloadMode,omitempty"`
|
||||||
|
FilenameStyle string `json:"filenameStyle,omitempty"`
|
||||||
|
DisableMetadata bool `json:"disableMetadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CobaltResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Filename string `json:"filename,omitempty"`
|
||||||
|
Error *struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Context *struct {
|
||||||
|
Service string `json:"service,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
} `json:"context,omitempty"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type YouTubeDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
|
Format string // "opus" or "mp3"
|
||||||
|
Bitrate int
|
||||||
|
LyricsLRC string
|
||||||
|
CoverData []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewYouTubeDownloader() *YouTubeDownloader {
|
||||||
|
youtubeDownloaderOnce.Do(func() {
|
||||||
|
globalYouTubeDownloader = &YouTubeDownloader{
|
||||||
|
client: NewHTTPClientWithTimeout(120 * time.Second),
|
||||||
|
apiURL: "https://api.qwkuns.me",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalYouTubeDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchYouTube returns a YouTube Music search URL for the given track
|
||||||
|
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
||||||
|
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||||
|
searchQuery := url.QueryEscape(query)
|
||||||
|
|
||||||
|
GoLog("[YouTube] Search query: %s\n", query)
|
||||||
|
|
||||||
|
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
|
||||||
|
|
||||||
|
return youtubeMusicURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
|
||||||
|
var audioFormat string
|
||||||
|
var audioBitrate string
|
||||||
|
|
||||||
|
switch quality {
|
||||||
|
case YouTubeQualityOpus256:
|
||||||
|
audioFormat = "opus"
|
||||||
|
audioBitrate = "256"
|
||||||
|
case YouTubeQualityMP3320:
|
||||||
|
audioFormat = "mp3"
|
||||||
|
audioBitrate = "320"
|
||||||
|
default:
|
||||||
|
audioFormat = "mp3"
|
||||||
|
audioBitrate = "320"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try SpotubeDL first (primary)
|
||||||
|
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
||||||
|
if extractErr == nil {
|
||||||
|
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
||||||
|
videoID, audioFormat, audioBitrate)
|
||||||
|
|
||||||
|
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
|
||||||
|
if err == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
||||||
|
} else {
|
||||||
|
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: direct Cobalt API (api.qwkuns.me)
|
||||||
|
cobaltURL := toYouTubeMusicURL(youtubeURL)
|
||||||
|
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
|
||||||
|
cobaltURL, audioFormat, audioBitrate)
|
||||||
|
|
||||||
|
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestCobaltDirect sends a download request to the primary Cobalt API.
|
||||||
|
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||||
|
reqBody := CobaltRequest{
|
||||||
|
URL: videoURL,
|
||||||
|
AudioFormat: audioFormat,
|
||||||
|
AudioBitrate: audioBitrate,
|
||||||
|
DownloadMode: "audio",
|
||||||
|
FilenameStyle: "basic",
|
||||||
|
DisableMetadata: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cobalt API request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var cobaltResp CobaltResponse
|
||||||
|
if err := json.Unmarshal(body, &cobaltResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
|
||||||
|
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
|
||||||
|
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cobaltResp.URL == "" {
|
||||||
|
return nil, fmt.Errorf("no download URL in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
|
||||||
|
return &cobaltResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
|
||||||
|
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||||
|
apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s",
|
||||||
|
videoID, audioFormat, audioBitrate)
|
||||||
|
|
||||||
|
GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("spotubedl request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode)
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.URL == "" {
|
||||||
|
return nil, fmt.Errorf("no download URL from spotubedl")
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Got download URL from SpotubeDL\n")
|
||||||
|
return &CobaltResponse{
|
||||||
|
Status: "tunnel",
|
||||||
|
URL: result.URL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if itemID != "" {
|
||||||
|
StartItemProgress(itemID)
|
||||||
|
defer CompleteItemProgress(itemID)
|
||||||
|
ctx = initDownloadCancel(itemID)
|
||||||
|
defer clearDownloadCancel(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||||
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
return fmt.Errorf("download request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
|
if expectedSize > 0 && itemID != "" {
|
||||||
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := openOutputForWrite(outputPath, outputFD)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
|
var written int64
|
||||||
|
if itemID != "" {
|
||||||
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
|
} else {
|
||||||
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
flushErr := bufWriter.Flush()
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if flushErr != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Download completed: %d bytes written\n", written)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildYouTubeSearchURL(trackName, artistName string) string {
|
||||||
|
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
|
||||||
|
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildYouTubeWatchURL(videoID string) string {
|
||||||
|
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isYouTubeVideoID checks if s is an 11-char YouTube video ID
|
||||||
|
func isYouTubeVideoID(s string) bool {
|
||||||
|
if len(s) != 11 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range s {
|
||||||
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsYouTubeURL(urlStr string) bool {
|
||||||
|
lower := strings.ToLower(urlStr)
|
||||||
|
return strings.Contains(lower, "youtube.com") ||
|
||||||
|
strings.Contains(lower, "youtu.be") ||
|
||||||
|
strings.Contains(lower, "music.youtube.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
|
||||||
|
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
|
||||||
|
func toYouTubeMusicURL(rawURL string) string {
|
||||||
|
videoID, err := ExtractYouTubeVideoID(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
||||||
|
if strings.Contains(urlStr, "youtu.be/") {
|
||||||
|
parts := strings.Split(urlStr, "youtu.be/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
videoID := strings.Split(parts[1], "?")[0]
|
||||||
|
videoID = strings.Split(videoID, "&")[0]
|
||||||
|
return strings.TrimSpace(videoID), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// /watch?v=
|
||||||
|
if v := parsed.Query().Get("v"); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /embed/
|
||||||
|
if strings.Contains(parsed.Path, "/embed/") {
|
||||||
|
parts := strings.Split(parsed.Path, "/embed/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return strings.Split(parts[1], "/")[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /v/
|
||||||
|
if strings.Contains(parsed.Path, "/v/") {
|
||||||
|
parts := strings.Split(parsed.Path, "/v/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return strings.Split(parts[1], "/")[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("could not extract video ID from URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||||
|
downloader := NewYouTubeDownloader()
|
||||||
|
|
||||||
|
var quality YouTubeQuality
|
||||||
|
switch strings.ToLower(req.Quality) {
|
||||||
|
case "opus_256", "opus256", "opus":
|
||||||
|
quality = YouTubeQualityOpus256
|
||||||
|
case "mp3_320", "mp3320", "mp3":
|
||||||
|
quality = YouTubeQualityMP3320
|
||||||
|
default:
|
||||||
|
quality = YouTubeQualityMP3320 // Default to MP3 320kbps
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
|
||||||
|
var youtubeURL string
|
||||||
|
var lookupErr error
|
||||||
|
|
||||||
|
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
|
||||||
|
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
|
||||||
|
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
|
||||||
|
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Spotify ID via SongLink
|
||||||
|
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
|
||||||
|
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
|
||||||
|
if lookupErr != nil {
|
||||||
|
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Deezer ID via SongLink
|
||||||
|
if youtubeURL == "" && req.DeezerID != "" {
|
||||||
|
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
|
||||||
|
if lookupErr != nil {
|
||||||
|
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ISRC via SongLink
|
||||||
|
if youtubeURL == "" && req.ISRC != "" {
|
||||||
|
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
|
||||||
|
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
|
||||||
|
youtubeURL = availability.YouTubeURL
|
||||||
|
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
|
||||||
|
} else if isrcErr != nil {
|
||||||
|
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cobalt requires direct video URLs, not search URLs
|
||||||
|
if youtubeURL == "" {
|
||||||
|
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
|
||||||
|
|
||||||
|
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
|
||||||
|
if err != nil {
|
||||||
|
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ext string
|
||||||
|
var format string
|
||||||
|
var bitrate int
|
||||||
|
switch quality {
|
||||||
|
case YouTubeQualityOpus256:
|
||||||
|
ext = ".opus"
|
||||||
|
format = "opus"
|
||||||
|
bitrate = 256
|
||||||
|
case YouTubeQualityMP3320:
|
||||||
|
ext = ".mp3"
|
||||||
|
format = "mp3"
|
||||||
|
bitrate = 320
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||||
|
"title": req.TrackName,
|
||||||
|
"artist": req.ArtistName,
|
||||||
|
"album": req.AlbumName,
|
||||||
|
"track": req.TrackNumber,
|
||||||
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
|
"disc": req.DiscNumber,
|
||||||
|
})
|
||||||
|
filename = sanitizeFilename(filename) + ext
|
||||||
|
|
||||||
|
var outputPath string
|
||||||
|
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||||
|
if isSafOutput {
|
||||||
|
outputPath = strings.TrimSpace(req.OutputPath)
|
||||||
|
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||||
|
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
outputPath = req.OutputDir + "/" + filename
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
||||||
|
|
||||||
|
// Parallel fetch cover art + lyrics
|
||||||
|
var parallelResult *ParallelDownloadResult
|
||||||
|
if req.EmbedLyrics || req.CoverURL != "" {
|
||||||
|
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
req.CoverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.EmbedLyrics,
|
||||||
|
int64(req.DurationMS),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||||
|
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lyricsLRC := ""
|
||||||
|
var coverData []byte
|
||||||
|
if parallelResult != nil {
|
||||||
|
if parallelResult.LyricsLRC != "" {
|
||||||
|
lyricsLRC = parallelResult.LyricsLRC
|
||||||
|
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
|
||||||
|
}
|
||||||
|
if parallelResult.CoverData != nil {
|
||||||
|
coverData = parallelResult.CoverData
|
||||||
|
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return YouTubeDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
Title: req.TrackName,
|
||||||
|
Artist: req.ArtistName,
|
||||||
|
Album: req.AlbumName,
|
||||||
|
ReleaseDate: req.ReleaseDate,
|
||||||
|
TrackNumber: req.TrackNumber,
|
||||||
|
DiscNumber: req.DiscNumber,
|
||||||
|
ISRC: req.ISRC,
|
||||||
|
Format: format,
|
||||||
|
Bitrate: bitrate,
|
||||||
|
LyricsLRC: lyricsLRC,
|
||||||
|
CoverData: coverData,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -46,6 +46,11 @@ post_install do |installer|
|
|||||||
flutter_additional_ios_build_settings(target)
|
flutter_additional_ios_build_settings(target)
|
||||||
target.build_configurations.each do |config|
|
target.build_configurations.each do |config|
|
||||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -83,15 +83,9 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "downloadTrack":
|
case "downloadByStrategy":
|
||||||
let requestJson = call.arguments as! String
|
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 }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -198,6 +192,17 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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":
|
case "embedLyricsToFile":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let filePath = args["file_path"] as! String
|
let filePath = args["file_path"] as! String
|
||||||
@@ -210,6 +215,41 @@ import Gobackend // Import Go framework
|
|||||||
GobackendCleanupConnections()
|
GobackendCleanupConnections()
|
||||||
return nil
|
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":
|
case "readFileMetadata":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let filePath = args["file_path"] as! String
|
let filePath = args["file_path"] as! String
|
||||||
@@ -217,6 +257,14 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "editFileMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let metadataJson = args["metadata_json"] as? String ?? "{}"
|
||||||
|
let response = GobackendEditFileMetadata(filePath, metadataJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "searchDeezerAll":
|
case "searchDeezerAll":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let query = args["query"] as! String
|
let query = args["query"] as! String
|
||||||
@@ -471,12 +519,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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":
|
case "enrichTrackWithExtension":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionId = args["extension_id"] as! String
|
let extensionId = args["extension_id"] as! String
|
||||||
@@ -485,6 +527,12 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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":
|
case "removeExtension":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionId = args["extension_id"] as! String
|
let extensionId = args["extension_id"] as! String
|
||||||
@@ -746,6 +794,36 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
|||||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
final _routerProvider = Provider<GoRouter>((ref) {
|
final _routerProvider = Provider<GoRouter>((ref) {
|
||||||
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
final isFirstLaunch = ref.watch(
|
||||||
final hasCompletedTutorial = ref.watch(settingsProvider.select((s) => s.hasCompletedTutorial));
|
settingsProvider.select((s) => s.isFirstLaunch),
|
||||||
|
);
|
||||||
|
final hasCompletedTutorial = ref.watch(
|
||||||
|
settingsProvider.select((s) => s.hasCompletedTutorial),
|
||||||
|
);
|
||||||
|
|
||||||
// Determine initial location based on app state
|
// Determine initial location based on app state
|
||||||
String initialLocation;
|
String initialLocation;
|
||||||
@@ -26,14 +30,8 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: initialLocation,
|
initialLocation: initialLocation,
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(path: '/', builder: (context, state) => const MainShell()),
|
||||||
path: '/',
|
GoRoute(path: '/setup', builder: (context, state) => const SetupScreen()),
|
||||||
builder: (context, state) => const MainShell(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/setup',
|
|
||||||
builder: (context, state) => const SetupScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/tutorial',
|
path: '/tutorial',
|
||||||
builder: (context, state) => const TutorialScreen(),
|
builder: (context, state) => const TutorialScreen(),
|
||||||
@@ -43,12 +41,17 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
class SpotiFLACApp extends ConsumerWidget {
|
class SpotiFLACApp extends ConsumerWidget {
|
||||||
const SpotiFLACApp({super.key});
|
final bool disableOverscrollEffects;
|
||||||
|
|
||||||
|
const SpotiFLACApp({super.key, this.disableOverscrollEffects = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final router = ref.watch(_routerProvider);
|
final router = ref.watch(_routerProvider);
|
||||||
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
||||||
|
final scrollBehavior = disableOverscrollEffects
|
||||||
|
? const MaterialScrollBehavior().copyWith(overscroll: false)
|
||||||
|
: null;
|
||||||
|
|
||||||
Locale? locale;
|
Locale? locale;
|
||||||
if (localeString != 'system') {
|
if (localeString != 'system') {
|
||||||
@@ -68,6 +71,7 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
theme: lightTheme,
|
theme: lightTheme,
|
||||||
darkTheme: darkTheme,
|
darkTheme: darkTheme,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
|
scrollBehavior: scrollBehavior,
|
||||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||||
themeAnimationCurve: Curves.easeInOut,
|
themeAnimationCurve: Curves.easeInOut,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.5.0';
|
static const String version = '3.6.7';
|
||||||
static const String buildNumber = '74';
|
static const String buildNumber = '81';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +17,5 @@ class AppInfo {
|
|||||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
|
|
||||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||||
static const String bmacUrl = 'https://buymeacoffee.com/zarzet';
|
|
||||||
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -712,6 +712,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Spotify requires your own API credentials. Get them free from developer.spotify.com'**
|
/// **'Spotify requires your own API credentials. Get them free from developer.spotify.com'**
|
||||||
String get optionsSpotifyWarning;
|
String get optionsSpotifyWarning;
|
||||||
|
|
||||||
|
/// Warning about Spotify API deprecation
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'**
|
||||||
|
String get optionsSpotifyDeprecationWarning;
|
||||||
|
|
||||||
/// Extensions page title
|
/// Extensions page title
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -922,18 +928,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Support'**
|
/// **'Support'**
|
||||||
String get aboutSupport;
|
String get aboutSupport;
|
||||||
|
|
||||||
/// Donation link
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Buy me a coffee'**
|
|
||||||
String get aboutBuyMeCoffee;
|
|
||||||
|
|
||||||
/// Subtitle for donation
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Support development on Ko-fi'**
|
|
||||||
String get aboutBuyMeCoffeeSubtitle;
|
|
||||||
|
|
||||||
/// Section for app info
|
/// Section for app info
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -988,6 +982,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'**
|
/// **'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'**
|
||||||
String get aboutDabMusicDesc;
|
String get aboutDabMusicDesc;
|
||||||
|
|
||||||
|
/// Name of SpotiSaver API service - DO NOT TRANSLATE
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SpotiSaver'**
|
||||||
|
String get aboutSpotiSaver;
|
||||||
|
|
||||||
|
/// Credit for SpotiSaver API
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!'**
|
||||||
|
String get aboutSpotiSaverDesc;
|
||||||
|
|
||||||
/// App description in header card
|
/// App description in header card
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2146,6 +2152,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'{artist} - {title}'**
|
/// **'{artist} - {title}'**
|
||||||
String filenameHint(Object artist, Object 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
|
/// Setting title - folder structure
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3484,6 +3502,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Actual quality depends on track availability from the service'**
|
/// **'Actual quality depends on track availability from the service'**
|
||||||
String get qualityNote;
|
String get qualityNote;
|
||||||
|
|
||||||
|
/// Note for YouTube service explaining lossy-only quality
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
|
||||||
|
String get youtubeQualityNote;
|
||||||
|
|
||||||
/// Setting - show quality picker
|
/// Setting - show quality picker
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3508,6 +3532,42 @@ abstract class AppLocalizations {
|
|||||||
/// **'Album Folder Structure'**
|
/// **'Album Folder Structure'**
|
||||||
String get downloadAlbumFolderStructure;
|
String get downloadAlbumFolderStructure;
|
||||||
|
|
||||||
|
/// Setting - choose whether artist folders use Album Artist or Track Artist
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Use Album Artist for folders'**
|
||||||
|
String get downloadUseAlbumArtistForFolders;
|
||||||
|
|
||||||
|
/// Subtitle when Album Artist is used for folder naming
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist folders use Album Artist when available'**
|
||||||
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle;
|
||||||
|
|
||||||
|
/// Subtitle when Track Artist is used for folder naming
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist folders use Track Artist only'**
|
||||||
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
|
||||||
|
|
||||||
|
/// Setting - strip featured artists from folder name
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Primary artist only for folders'**
|
||||||
|
String get downloadUsePrimaryArtistOnly;
|
||||||
|
|
||||||
|
/// Subtitle when primary artist only is enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)'**
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled;
|
||||||
|
|
||||||
|
/// Subtitle when primary artist only is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Full artist string used for folder name'**
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled;
|
||||||
|
|
||||||
/// Setting - output file format
|
/// Setting - output file format
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3922,6 +3982,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Playlist'**
|
/// **'Playlist'**
|
||||||
String get recentTypePlaylist;
|
String get recentTypePlaylist;
|
||||||
|
|
||||||
|
/// Empty state text for recent access list
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No recent items yet'**
|
||||||
|
String get recentEmpty;
|
||||||
|
|
||||||
|
/// Button label to unhide hidden downloads in recent access
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show All Downloads'**
|
||||||
|
String get recentShowAllDownloads;
|
||||||
|
|
||||||
/// Snackbar message when tapping playlist in recent access
|
/// Snackbar message when tapping playlist in recent access
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -4090,6 +4162,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Scan music & detect duplicates'**
|
/// **'Scan music & detect duplicates'**
|
||||||
String get settingsLocalLibrarySubtitle;
|
String get settingsLocalLibrarySubtitle;
|
||||||
|
|
||||||
|
/// Settings menu item - cache management
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Storage & Cache'**
|
||||||
|
String get settingsCache;
|
||||||
|
|
||||||
|
/// Subtitle for cache management menu
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'View size and clear cached data'**
|
||||||
|
String get settingsCacheSubtitle;
|
||||||
|
|
||||||
/// Library settings page title
|
/// Library settings page title
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -4396,6 +4480,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'This Year'**
|
/// **'This Year'**
|
||||||
String get libraryFilterDateYear;
|
String get libraryFilterDateYear;
|
||||||
|
|
||||||
|
/// Filter section - sort order
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sort'**
|
||||||
|
String get libraryFilterSort;
|
||||||
|
|
||||||
|
/// Sort option - newest first
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Latest'**
|
||||||
|
String get libraryFilterSortLatest;
|
||||||
|
|
||||||
|
/// Sort option - oldest first
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Oldest'**
|
||||||
|
String get libraryFilterSortOldest;
|
||||||
|
|
||||||
/// Badge showing number of active filters
|
/// Badge showing number of active filters
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -4755,6 +4857,370 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'No orphaned entries found'**
|
/// **'No orphaned entries found'**
|
||||||
String get cleanupOrphanedDownloadsNone;
|
String get cleanupOrphanedDownloadsNone;
|
||||||
|
|
||||||
|
/// Cache management page title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Storage & Cache'**
|
||||||
|
String get cacheTitle;
|
||||||
|
|
||||||
|
/// Heading for cache summary card
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cache overview'**
|
||||||
|
String get cacheSummaryTitle;
|
||||||
|
|
||||||
|
/// Helper text for cache summary card
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clearing cache will not remove downloaded music files.'**
|
||||||
|
String get cacheSummarySubtitle;
|
||||||
|
|
||||||
|
/// Total cache size shown in summary
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Estimated cache usage: {size}'**
|
||||||
|
String cacheEstimatedTotal(String size);
|
||||||
|
|
||||||
|
/// Section header for cache entries
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cached Data'**
|
||||||
|
String get cacheSectionStorage;
|
||||||
|
|
||||||
|
/// Section header for cleanup actions
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Maintenance'**
|
||||||
|
String get cacheSectionMaintenance;
|
||||||
|
|
||||||
|
/// Cache item title for app cache directory
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'App cache directory'**
|
||||||
|
String get cacheAppDirectory;
|
||||||
|
|
||||||
|
/// Description of what app cache directory contains
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'HTTP responses, WebView data, and other temporary app data.'**
|
||||||
|
String get cacheAppDirectoryDesc;
|
||||||
|
|
||||||
|
/// Cache item title for temporary files directory
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Temporary directory'**
|
||||||
|
String get cacheTempDirectory;
|
||||||
|
|
||||||
|
/// Description of what temporary directory contains
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Temporary files from downloads and audio conversion.'**
|
||||||
|
String get cacheTempDirectoryDesc;
|
||||||
|
|
||||||
|
/// Cache item title for persistent cover images
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cover image cache'**
|
||||||
|
String get cacheCoverImage;
|
||||||
|
|
||||||
|
/// Description of what cover image cache contains
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Downloaded album and track cover art. Will re-download when viewed.'**
|
||||||
|
String get cacheCoverImageDesc;
|
||||||
|
|
||||||
|
/// Cache item title for local library cover art images
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Library cover cache'**
|
||||||
|
String get cacheLibraryCover;
|
||||||
|
|
||||||
|
/// Description of what library cover cache contains
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cover art extracted from local music files. Will re-extract on next scan.'**
|
||||||
|
String get cacheLibraryCoverDesc;
|
||||||
|
|
||||||
|
/// Cache item title for explore home feed cache
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Explore feed cache'**
|
||||||
|
String get cacheExploreFeed;
|
||||||
|
|
||||||
|
/// Description of what explore feed cache contains
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Explore tab content (new releases, trending). Will refresh on next visit.'**
|
||||||
|
String get cacheExploreFeedDesc;
|
||||||
|
|
||||||
|
/// Cache item title for track ID lookup cache
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Track lookup cache'**
|
||||||
|
String get cacheTrackLookup;
|
||||||
|
|
||||||
|
/// Description of what track lookup cache contains
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Spotify/Deezer track ID lookups. Clearing may slow next few searches.'**
|
||||||
|
String get cacheTrackLookupDesc;
|
||||||
|
|
||||||
|
/// Description of what cleanup unused data does
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove orphaned download history and library entries for missing files.'**
|
||||||
|
String get cacheCleanupUnusedDesc;
|
||||||
|
|
||||||
|
/// Label when cache category has no data
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No cached data'**
|
||||||
|
String get cacheNoData;
|
||||||
|
|
||||||
|
/// Cache size and file count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{size} in {count} files'**
|
||||||
|
String cacheSizeWithFiles(String size, int count);
|
||||||
|
|
||||||
|
/// Cache size only
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{size}'**
|
||||||
|
String cacheSizeOnly(String size);
|
||||||
|
|
||||||
|
/// Track cache entry count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} entries'**
|
||||||
|
String cacheEntries(int count);
|
||||||
|
|
||||||
|
/// Snackbar after clearing selected cache
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cleared: {target}'**
|
||||||
|
String cacheClearSuccess(String target);
|
||||||
|
|
||||||
|
/// Dialog title before clearing one cache category
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear cache?'**
|
||||||
|
String get cacheClearConfirmTitle;
|
||||||
|
|
||||||
|
/// Dialog message before clearing selected cache
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This will clear cached data for {target}. Downloaded music files will not be deleted.'**
|
||||||
|
String cacheClearConfirmMessage(String target);
|
||||||
|
|
||||||
|
/// Dialog title before clearing all caches
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear all cache?'**
|
||||||
|
String get cacheClearAllConfirmTitle;
|
||||||
|
|
||||||
|
/// Dialog message before clearing all caches
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This will clear all cache categories on this page. Downloaded music files will not be deleted.'**
|
||||||
|
String get cacheClearAllConfirmMessage;
|
||||||
|
|
||||||
|
/// Button label to clear all caches
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear all cache'**
|
||||||
|
String get cacheClearAll;
|
||||||
|
|
||||||
|
/// Action title for cleaning unused entries
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cleanup unused data'**
|
||||||
|
String get cacheCleanupUnused;
|
||||||
|
|
||||||
|
/// Subtitle for cleanup unused data action
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove orphaned download history and missing library entries'**
|
||||||
|
String get cacheCleanupUnusedSubtitle;
|
||||||
|
|
||||||
|
/// Snackbar after unused data cleanup
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries'**
|
||||||
|
String cacheCleanupResult(int downloadCount, int libraryCount);
|
||||||
|
|
||||||
|
/// Button label to refresh cache statistics
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Refresh stats'**
|
||||||
|
String get cacheRefreshStats;
|
||||||
|
|
||||||
|
/// Menu action - save album cover art as file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save Cover Art'**
|
||||||
|
String get trackSaveCoverArt;
|
||||||
|
|
||||||
|
/// Subtitle for save cover art action
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save album art as .jpg file'**
|
||||||
|
String get trackSaveCoverArtSubtitle;
|
||||||
|
|
||||||
|
/// Menu action - save lyrics as .lrc file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save Lyrics (.lrc)'**
|
||||||
|
String get trackSaveLyrics;
|
||||||
|
|
||||||
|
/// Subtitle for save lyrics action
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Fetch and save lyrics as .lrc file'**
|
||||||
|
String get trackSaveLyricsSubtitle;
|
||||||
|
|
||||||
|
/// Snackbar while saving lyrics to file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Saving lyrics...'**
|
||||||
|
String get trackSaveLyricsProgress;
|
||||||
|
|
||||||
|
/// Menu action - re-embed metadata into audio file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Re-enrich Metadata'**
|
||||||
|
String get trackReEnrich;
|
||||||
|
|
||||||
|
/// Subtitle for re-enrich metadata action
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Re-embed metadata without re-downloading'**
|
||||||
|
String get trackReEnrichSubtitle;
|
||||||
|
|
||||||
|
/// Subtitle for re-enrich metadata action for local items
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search metadata online and embed into file'**
|
||||||
|
String get trackReEnrichOnlineSubtitle;
|
||||||
|
|
||||||
|
/// Menu action - edit embedded metadata
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit Metadata'**
|
||||||
|
String get trackEditMetadata;
|
||||||
|
|
||||||
|
/// Snackbar after cover art saved
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cover art saved to {fileName}'**
|
||||||
|
String trackCoverSaved(String fileName);
|
||||||
|
|
||||||
|
/// Snackbar when no cover art URL or embedded cover
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No cover art source available'**
|
||||||
|
String get trackCoverNoSource;
|
||||||
|
|
||||||
|
/// Snackbar after lyrics saved
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lyrics saved to {fileName}'**
|
||||||
|
String trackLyricsSaved(String fileName);
|
||||||
|
|
||||||
|
/// Snackbar while re-enriching metadata
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Re-enriching metadata...'**
|
||||||
|
String get trackReEnrichProgress;
|
||||||
|
|
||||||
|
/// Snackbar while searching metadata from internet for local items
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Searching metadata online...'**
|
||||||
|
String get trackReEnrichSearching;
|
||||||
|
|
||||||
|
/// Snackbar after successful re-enrichment
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Metadata re-enriched successfully'**
|
||||||
|
String get trackReEnrichSuccess;
|
||||||
|
|
||||||
|
/// Snackbar when FFmpeg embed fails for MP3/Opus
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'FFmpeg metadata embed failed'**
|
||||||
|
String get trackReEnrichFfmpegFailed;
|
||||||
|
|
||||||
|
/// Snackbar when save operation fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed: {error}'**
|
||||||
|
String trackSaveFailed(String error);
|
||||||
|
|
||||||
|
/// Menu item - convert audio format
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert Format'**
|
||||||
|
String get trackConvertFormat;
|
||||||
|
|
||||||
|
/// Subtitle for convert format menu item
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert to MP3 or Opus'**
|
||||||
|
String get trackConvertFormatSubtitle;
|
||||||
|
|
||||||
|
/// Title of convert bottom sheet
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert Audio'**
|
||||||
|
String get trackConvertTitle;
|
||||||
|
|
||||||
|
/// Label for format selection
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Target Format'**
|
||||||
|
String get trackConvertTargetFormat;
|
||||||
|
|
||||||
|
/// Label for bitrate selection
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bitrate'**
|
||||||
|
String get trackConvertBitrate;
|
||||||
|
|
||||||
|
/// Confirmation dialog title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Confirm Conversion'**
|
||||||
|
String get trackConvertConfirmTitle;
|
||||||
|
|
||||||
|
/// Confirmation dialog message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.'**
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Snackbar while converting
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Converting audio...'**
|
||||||
|
String get trackConvertConverting;
|
||||||
|
|
||||||
|
/// Snackbar after successful conversion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Converted to {format} successfully'**
|
||||||
|
String trackConvertSuccess(String format);
|
||||||
|
|
||||||
|
/// Snackbar when conversion fails
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Conversion failed'**
|
||||||
|
String get trackConvertFailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -343,6 +343,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get optionsSpotifyWarning =>
|
String get optionsSpotifyWarning =>
|
||||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
|
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Extensions';
|
String get extensionsTitle => 'Extensions';
|
||||||
|
|
||||||
@@ -453,12 +457,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -491,6 +489,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get aboutDabMusicDesc =>
|
String get aboutDabMusicDesc =>
|
||||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaver => 'SpotiSaver';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaverDesc =>
|
||||||
|
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||||
@@ -1177,6 +1182,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'Folder Organization';
|
String get folderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -1907,6 +1919,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeQualityNote =>
|
||||||
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1919,6 +1935,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
|
'Artist folders use Album Artist when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2154,6 +2192,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get recentTypePlaylist => 'Playlist';
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentShowAllDownloads => 'Show All Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
return 'Playlist: $name';
|
return 'Playlist: $name';
|
||||||
@@ -2260,6 +2304,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCache => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryTitle => 'Local Library';
|
String get libraryTitle => 'Local Library';
|
||||||
|
|
||||||
@@ -2427,6 +2477,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterDateYear => 'This Year';
|
String get libraryFilterDateYear => 'This Year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortLatest => 'Latest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryFilterActive(int count) {
|
String libraryFilterActive(int count) {
|
||||||
return '$count filter(s) active';
|
return '$count filter(s) active';
|
||||||
@@ -2663,4 +2722,224 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTitle => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummaryTitle => 'Cache overview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummarySubtitle =>
|
||||||
|
'Clearing cache will not remove downloaded music files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEstimatedTotal(String size) {
|
||||||
|
return 'Estimated cache usage: $size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionStorage => 'Cached Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionMaintenance => 'Maintenance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectory => 'App cache directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectoryDesc =>
|
||||||
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectory => 'Temporary directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectoryDesc =>
|
||||||
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImage => 'Cover image cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImageDesc =>
|
||||||
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCover => 'Library cover cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCoverDesc =>
|
||||||
|
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeed => 'Explore feed cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeedDesc =>
|
||||||
|
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookup => 'Track lookup cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookupDesc =>
|
||||||
|
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedDesc =>
|
||||||
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheNoData => 'No cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
|
return '$size in $count files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeOnly(String size) {
|
||||||
|
return '$size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEntries(int count) {
|
||||||
|
return '$count entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearSuccess(String target) {
|
||||||
|
return 'Cleared: $target';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearConfirmMessage(String target) {
|
||||||
|
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||||
|
|
||||||
|
@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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
|
'Remove orphaned download history and missing library entries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
|
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefreshStats => 'Refresh stats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArt => 'Save Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSubtitle =>
|
||||||
|
'Re-embed metadata without re-downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCoverSaved(String fileName) {
|
||||||
|
return 'Cover art saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverNoSource => 'No cover art source available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackLyricsSaved(String fileName) {
|
||||||
|
return 'Lyrics saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackSaveFailed(String error) {
|
||||||
|
return 'Failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,62 +13,62 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get appDescription =>
|
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
|
@override
|
||||||
String get navHome => 'Home';
|
String get navHome => 'Accueil';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navLibrary => 'Library';
|
String get navLibrary => 'Bibliothèques';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHistory => 'History';
|
String get navHistory => 'Historique';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navSettings => 'Settings';
|
String get navSettings => 'Paramètres';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navStore => 'Store';
|
String get navStore => 'Magasin';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeTitle => 'Home';
|
String get homeTitle => 'Accueil';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSearchHint => 'Paste Spotify URL or search...';
|
String get homeSearchHint => 'Coller l\'URL Spotify ou rechercher...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String homeSearchHintExtension(String extensionName) {
|
String homeSearchHintExtension(String extensionName) {
|
||||||
return 'Search with $extensionName...';
|
return 'Rechercher avec $extensionName...';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
String get homeSupports => 'Supports: Piste, Album, Playlist, Artiste URLs';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeRecent => 'Recent';
|
String get homeRecent => 'Récent';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyTitle => 'History';
|
String get historyTitle => 'Historique';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String historyDownloading(int count) {
|
String historyDownloading(int count) {
|
||||||
return 'Downloading ($count)';
|
return 'Téléchargement ($count)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyDownloaded => 'Downloaded';
|
String get historyDownloaded => 'Téléchargé';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterAll => 'All';
|
String get historyFilterAll => 'Tous';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterAlbums => 'Albums';
|
String get historyFilterAlbums => 'Albums';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterSingles => 'Singles';
|
String get historyFilterSingles => 'Titres';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String historyTracksCount(int count) {
|
String historyTracksCount(int count) {
|
||||||
@@ -93,36 +93,37 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoDownloads => 'No download history';
|
String get historyNoDownloads => 'Pas d\'historique de téléchargement';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here';
|
String get historyNoDownloadsSubtitle =>
|
||||||
|
'Les pistes téléchargées apparaîtront ici';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoAlbums => 'No album downloads';
|
String get historyNoAlbums => 'Pas de téléchargement d\'album';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoAlbumsSubtitle =>
|
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
|
@override
|
||||||
String get historyNoSingles => 'No single downloads';
|
String get historyNoSingles => 'Pas de téléchargements uniques';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyNoSinglesSubtitle =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Les téléchargements de pistes uniques apparaîtront ici';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historySearchHint => 'Search history...';
|
String get historySearchHint => 'Historique de recherche...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Paramètres';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsDownload => 'Download';
|
String get settingsDownload => 'Télécharger';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAppearance => 'Appearance';
|
String get settingsAppearance => 'Apparence';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsOptions => 'Options';
|
String get settingsOptions => 'Options';
|
||||||
@@ -131,51 +132,54 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get settingsExtensions => 'Extensions';
|
String get settingsExtensions => 'Extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsAbout => 'About';
|
String get settingsAbout => 'À propos';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadTitle => 'Download';
|
String get downloadTitle => 'Télécharger';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadLocation => 'Download Location';
|
String get downloadLocation => 'Télécharger Localisation';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadLocationSubtitle => 'Choose where to save files';
|
String get downloadLocationSubtitle =>
|
||||||
|
'Choisissez où enregistrer des fichiers';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadLocationDefault => 'Default location';
|
String get downloadLocationDefault => 'Localisation par défaut';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDefaultService => 'Default Service';
|
String get downloadDefaultService => 'Service par défaut';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDefaultServiceSubtitle => 'Service used for downloads';
|
String get downloadDefaultServiceSubtitle =>
|
||||||
|
'Service utilisé pour les téléchargements';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadDefaultQuality => 'Default Quality';
|
String get downloadDefaultQuality => 'Qualité par défaut';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskQuality => 'Ask Quality Before Download';
|
String get downloadAskQuality =>
|
||||||
|
'Demandez La Qualité Avant Le Téléchargement';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskQualitySubtitle =>
|
String get downloadAskQualitySubtitle =>
|
||||||
'Show quality picker for each download';
|
'Afficher le sélecteur de qualité pour chaque téléchargement';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFilenameFormat => 'Filename Format';
|
String get downloadFilenameFormat => 'Nom du fichier';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadFolderOrganization => 'Folder Organization';
|
String get downloadFolderOrganization => 'Organisation du dossier';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSeparateSingles => 'Separate Singles';
|
String get downloadSeparateSingles => 'Titres séparés';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSeparateSinglesSubtitle =>
|
String get downloadSeparateSinglesSubtitle =>
|
||||||
'Put single tracks in a separate folder';
|
'Mettre des pistes uniques dans un dossier séparé';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityBest => 'Best Available';
|
String get qualityBest => 'Meilleur Disponible';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityFlac => 'FLAC';
|
String get qualityFlac => 'FLAC';
|
||||||
@@ -187,69 +191,71 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get quality128 => '128 kbps';
|
String get quality128 => '128 kbps';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceTitle => 'Appearance';
|
String get appearanceTitle => 'Apparence';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceTheme => 'Theme';
|
String get appearanceTheme => 'Thème';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceThemeSystem => 'System';
|
String get appearanceThemeSystem => 'Système';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceThemeLight => 'Light';
|
String get appearanceThemeLight => 'Clair';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceThemeDark => 'Dark';
|
String get appearanceThemeDark => 'Sombre';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceDynamicColor => 'Dynamic Color';
|
String get appearanceDynamicColor => 'Couleur dynamique';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
|
String get appearanceDynamicColorSubtitle =>
|
||||||
|
'Utilisez les couleurs de votre fond d\'écran';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceAccentColor => 'Accent Color';
|
String get appearanceAccentColor => 'Couleur d\'accent';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceHistoryView => 'History View';
|
String get appearanceHistoryView => 'Historique Vue';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceHistoryViewList => 'List';
|
String get appearanceHistoryViewList => '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceHistoryViewGrid => 'Grid';
|
String get appearanceHistoryViewGrid => 'Grille';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsTitle => 'Options';
|
String get optionsTitle => 'Options';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSearchSource => 'Search Source';
|
String get optionsSearchSource => 'Recherche Source';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsPrimaryProvider => 'Primary Provider';
|
String get optionsPrimaryProvider => 'Fournisseur principal';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsPrimaryProviderSubtitle =>
|
String get optionsPrimaryProviderSubtitle =>
|
||||||
'Service used when searching by track name.';
|
'Service utilisé lors de la recherche par nom de piste.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String optionsUsingExtension(String extensionName) {
|
String optionsUsingExtension(String extensionName) {
|
||||||
return 'Using extension: $extensionName';
|
return 'Utilisation de l\'extension: $extensionName';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSwitchBack =>
|
String get optionsSwitchBack =>
|
||||||
'Tap Deezer or Spotify to switch back from extension';
|
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsAutoFallback => 'Auto Fallback';
|
String get optionsAutoFallback => 'Auto Fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsAutoFallbackSubtitle =>
|
String get optionsAutoFallbackSubtitle =>
|
||||||
'Try other services if download fails';
|
'Essayez d\'autres services si le téléchargement échoue';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
String get optionsUseExtensionProviders =>
|
||||||
|
'Utiliser des fournisseurs d\'extension';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||||
@@ -343,6 +349,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get optionsSpotifyWarning =>
|
String get optionsSpotifyWarning =>
|
||||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
|
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Extensions';
|
String get extensionsTitle => 'Extensions';
|
||||||
|
|
||||||
@@ -372,16 +382,16 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsUninstall => 'Uninstall';
|
String get extensionsUninstall => 'Désinstaller';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsSetAsSearch => 'Set as Search Provider';
|
String get extensionsSetAsSearch => 'Défini comme fournisseur de recherche';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeTitle => 'Extension Store';
|
String get storeTitle => 'Magasin d\'extension';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeSearch => 'Search extensions...';
|
String get storeSearch => 'Recherche d\'extensions...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get storeInstall => 'Install';
|
String get storeInstall => 'Install';
|
||||||
@@ -453,12 +463,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -491,6 +495,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get aboutDabMusicDesc =>
|
String get aboutDabMusicDesc =>
|
||||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaver => 'SpotiSaver';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaverDesc =>
|
||||||
|
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||||
@@ -562,7 +573,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get trackMetadataDuration => 'Duration';
|
String get trackMetadataDuration => 'Duration';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataQuality => 'Quality';
|
String get trackMetadataQuality => '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataPath => 'File Path';
|
String get trackMetadataPath => 'File Path';
|
||||||
@@ -574,38 +585,38 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get trackMetadataService => 'Service';
|
String get trackMetadataService => 'Service';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataPlay => 'Play';
|
String get trackMetadataPlay => 'Jouer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataShare => 'Share';
|
String get trackMetadataShare => 'Partager';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataDelete => 'Delete';
|
String get trackMetadataDelete => 'Supprimer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataRedownload => 'Re-download';
|
String get trackMetadataRedownload => 'Re-télécharger';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackMetadataOpenFolder => 'Open Folder';
|
String get trackMetadataOpenFolder => 'Dossier ouvert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupTitle => 'Welcome to SpotiFLAC';
|
String get setupTitle => 'Bienvenue chez SpotiFLAC';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupSubtitle => 'Let\'s get you started';
|
String get setupSubtitle => 'On va commencer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStoragePermission => 'Storage Permission';
|
String get setupStoragePermission => 'Permission de stockage';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStoragePermissionSubtitle =>
|
String get setupStoragePermissionSubtitle =>
|
||||||
'Required to save downloaded files';
|
'Requis pour enregistrer les fichiers téléchargés';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStoragePermissionGranted => 'Permission granted';
|
String get setupStoragePermissionGranted => 'Permission accordée';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupStoragePermissionDenied => 'Permission denied';
|
String get setupStoragePermissionDenied => 'Permission refusée';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupGrantPermission => 'Grant Permission';
|
String get setupGrantPermission => 'Grant Permission';
|
||||||
@@ -730,14 +741,14 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
'Get notified when downloads complete or require attention.';
|
'Get notified when downloads complete or require attention.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupFolderSelected => 'Download Folder Selected!';
|
String get setupFolderSelected => 'Dossier de téléchargement sélectionné!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupFolderChoose => 'Choose Download Folder';
|
String get setupFolderChoose => 'Choisissez le dossier pour télécharger';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupFolderDescription =>
|
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
|
@override
|
||||||
String get setupChangeFolder => 'Change Folder';
|
String get setupChangeFolder => 'Change Folder';
|
||||||
@@ -1177,6 +1188,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'Folder Organization';
|
String get folderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -1907,6 +1925,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeQualityNote =>
|
||||||
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1919,6 +1941,28 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
|
'Artist folders use Album Artist when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2154,6 +2198,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get recentTypePlaylist => 'Playlist';
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentShowAllDownloads => 'Show All Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
return 'Playlist: $name';
|
return 'Playlist: $name';
|
||||||
@@ -2260,6 +2310,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCache => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryTitle => 'Local Library';
|
String get libraryTitle => 'Local Library';
|
||||||
|
|
||||||
@@ -2427,6 +2483,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterDateYear => 'This Year';
|
String get libraryFilterDateYear => 'This Year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortLatest => 'Latest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryFilterActive(int count) {
|
String libraryFilterActive(int count) {
|
||||||
return '$count filter(s) active';
|
return '$count filter(s) active';
|
||||||
@@ -2663,4 +2728,224 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTitle => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummaryTitle => 'Cache overview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummarySubtitle =>
|
||||||
|
'Clearing cache will not remove downloaded music files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEstimatedTotal(String size) {
|
||||||
|
return 'Estimated cache usage: $size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionStorage => 'Cached Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionMaintenance => 'Maintenance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectory => 'App cache directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectoryDesc =>
|
||||||
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectory => 'Temporary directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectoryDesc =>
|
||||||
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImage => 'Cover image cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImageDesc =>
|
||||||
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCover => 'Library cover cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCoverDesc =>
|
||||||
|
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeed => 'Explore feed cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeedDesc =>
|
||||||
|
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookup => 'Track lookup cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookupDesc =>
|
||||||
|
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedDesc =>
|
||||||
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheNoData => 'No cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
|
return '$size in $count files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeOnly(String size) {
|
||||||
|
return '$size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEntries(int count) {
|
||||||
|
return '$count entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearSuccess(String target) {
|
||||||
|
return 'Cleared: $target';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearConfirmMessage(String target) {
|
||||||
|
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||||
|
|
||||||
|
@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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
|
'Remove orphaned download history and missing library entries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
|
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefreshStats => 'Refresh stats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArt => 'Save Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSubtitle =>
|
||||||
|
'Re-embed metadata without re-downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCoverSaved(String fileName) {
|
||||||
|
return 'Cover art saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverNoSource => 'No cover art source available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackLyricsSaved(String fileName) {
|
||||||
|
return 'Lyrics saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackSaveFailed(String error) {
|
||||||
|
return 'Failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,6 +343,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get optionsSpotifyWarning =>
|
String get optionsSpotifyWarning =>
|
||||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
|
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Extensions';
|
String get extensionsTitle => 'Extensions';
|
||||||
|
|
||||||
@@ -453,12 +457,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -491,6 +489,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get aboutDabMusicDesc =>
|
String get aboutDabMusicDesc =>
|
||||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaver => 'SpotiSaver';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaverDesc =>
|
||||||
|
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||||
@@ -1177,6 +1182,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'Folder Organization';
|
String get folderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -1907,6 +1919,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeQualityNote =>
|
||||||
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1919,6 +1935,28 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
|
'Artist folders use Album Artist when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2154,6 +2192,12 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get recentTypePlaylist => 'Playlist';
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentShowAllDownloads => 'Show All Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
return 'Playlist: $name';
|
return 'Playlist: $name';
|
||||||
@@ -2260,6 +2304,12 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCache => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryTitle => 'Local Library';
|
String get libraryTitle => 'Local Library';
|
||||||
|
|
||||||
@@ -2427,6 +2477,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterDateYear => 'This Year';
|
String get libraryFilterDateYear => 'This Year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortLatest => 'Latest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryFilterActive(int count) {
|
String libraryFilterActive(int count) {
|
||||||
return '$count filter(s) active';
|
return '$count filter(s) active';
|
||||||
@@ -2663,4 +2722,224 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTitle => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummaryTitle => 'Cache overview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummarySubtitle =>
|
||||||
|
'Clearing cache will not remove downloaded music files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEstimatedTotal(String size) {
|
||||||
|
return 'Estimated cache usage: $size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionStorage => 'Cached Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionMaintenance => 'Maintenance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectory => 'App cache directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectoryDesc =>
|
||||||
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectory => 'Temporary directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectoryDesc =>
|
||||||
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImage => 'Cover image cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImageDesc =>
|
||||||
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCover => 'Library cover cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCoverDesc =>
|
||||||
|
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeed => 'Explore feed cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeedDesc =>
|
||||||
|
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookup => 'Track lookup cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookupDesc =>
|
||||||
|
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedDesc =>
|
||||||
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheNoData => 'No cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
|
return '$size in $count files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeOnly(String size) {
|
||||||
|
return '$size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEntries(int count) {
|
||||||
|
return '$count entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearSuccess(String target) {
|
||||||
|
return 'Cleared: $target';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearConfirmMessage(String target) {
|
||||||
|
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||||
|
|
||||||
|
@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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
|
'Remove orphaned download history and missing library entries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
|
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefreshStats => 'Refresh stats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArt => 'Save Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSubtitle =>
|
||||||
|
'Re-embed metadata without re-downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCoverSaved(String fileName) {
|
||||||
|
return 'Cover art saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverNoSource => 'No cover art source available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackLyricsSaved(String fileName) {
|
||||||
|
return 'Lyrics saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackSaveFailed(String error) {
|
||||||
|
return 'Failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,6 +347,10 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get optionsSpotifyWarning =>
|
String get optionsSpotifyWarning =>
|
||||||
'Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com';
|
'Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
|
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Ekstensi';
|
String get extensionsTitle => 'Ekstensi';
|
||||||
|
|
||||||
@@ -458,12 +462,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Dukungan';
|
String get aboutSupport => 'Dukungan';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Belikan saya kopi';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Dukung pengembangan di Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'Aplikasi';
|
String get aboutApp => 'Aplikasi';
|
||||||
|
|
||||||
@@ -496,6 +494,13 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get aboutDabMusicDesc =>
|
String get aboutDabMusicDesc =>
|
||||||
'API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!';
|
'API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaver => 'SpotiSaver';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaverDesc =>
|
||||||
|
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
|
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
|
||||||
@@ -1183,6 +1188,13 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
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
|
@override
|
||||||
String get folderOrganization => 'Organisasi Folder';
|
String get folderOrganization => 'Organisasi Folder';
|
||||||
|
|
||||||
@@ -1919,6 +1931,10 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeQualityNote =>
|
||||||
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
||||||
|
|
||||||
@@ -1931,6 +1947,28 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
|
'Artist folders use Album Artist when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Simpan Format';
|
String get downloadSaveFormat => 'Simpan Format';
|
||||||
|
|
||||||
@@ -2167,6 +2205,12 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get recentTypePlaylist => 'Playlist';
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentShowAllDownloads => 'Show All Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
return 'Playlist: $name';
|
return 'Playlist: $name';
|
||||||
@@ -2273,6 +2317,12 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCache => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryTitle => 'Local Library';
|
String get libraryTitle => 'Local Library';
|
||||||
|
|
||||||
@@ -2440,6 +2490,15 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterDateYear => 'This Year';
|
String get libraryFilterDateYear => 'This Year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortLatest => 'Latest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryFilterActive(int count) {
|
String libraryFilterActive(int count) {
|
||||||
return '$count filter(s) active';
|
return '$count filter(s) active';
|
||||||
@@ -2537,145 +2596,363 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get storageModeInfo => 'Your files are stored in multiple locations';
|
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
|
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeDesc =>
|
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
|
@override
|
||||||
String get tutorialWelcomeTip1 =>
|
String get tutorialWelcomeTip1 =>
|
||||||
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
|
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music';
|
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
'Metadata, cover art, dan lirik otomatis tertanam';
|
'Automatic metadata, cover art, and lyrics embedding';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSearchTitle => 'Mencari Musik';
|
String get tutorialSearchTitle => 'Finding Music';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSearchDesc =>
|
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
|
@override
|
||||||
String get tutorialSearchTip1 =>
|
String get tutorialSearchTip1 =>
|
||||||
'Tempel URL Spotify atau Deezer langsung di kotak pencarian';
|
'Paste a Spotify or Deezer URL directly in the search box';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSearchTip2 =>
|
String get tutorialSearchTip2 =>
|
||||||
'Atau ketik nama lagu, artis, atau album untuk mencari';
|
'Or type the song name, artist, or album to search';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSearchTip3 =>
|
String get tutorialSearchTip3 =>
|
||||||
'Mendukung lagu, album, playlist, dan halaman artis';
|
'Supports tracks, albums, playlists, and artist pages';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialDownloadTitle => 'Mengunduh Musik';
|
String get tutorialDownloadTitle => 'Downloading Music';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialDownloadDesc =>
|
String get tutorialDownloadDesc =>
|
||||||
'Mengunduh musik itu mudah dan cepat. Begini caranya.';
|
'Downloading music is simple and fast. Here\'s how it works.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialDownloadTip1 =>
|
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
|
@override
|
||||||
String get tutorialDownloadTip2 =>
|
String get tutorialDownloadTip2 =>
|
||||||
'Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)';
|
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialDownloadTip3 =>
|
String get tutorialDownloadTip3 =>
|
||||||
'Unduh seluruh album atau playlist dengan satu ketukan';
|
'Download entire albums or playlists with one tap';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryTitle => 'Perpustakaan Anda';
|
String get tutorialLibraryTitle => 'Your Library';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryDesc =>
|
String get tutorialLibraryDesc =>
|
||||||
'Semua musik yang Anda unduh terorganisir di tab Perpustakaan.';
|
'All your downloaded music is organized in the Library tab.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryTip1 =>
|
String get tutorialLibraryTip1 =>
|
||||||
'Lihat progres unduhan dan antrian di tab Perpustakaan';
|
'View download progress and queue in the Library tab';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryTip2 =>
|
String get tutorialLibraryTip2 =>
|
||||||
'Ketuk lagu mana pun untuk memutarnya dengan pemutar musik';
|
'Tap any track to play it with your music player';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialLibraryTip3 =>
|
String get tutorialLibraryTip3 =>
|
||||||
'Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik';
|
'Switch between list and grid view for better browsing';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTitle => 'Ekstensi';
|
String get tutorialExtensionsTitle => 'Extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsDesc =>
|
String get tutorialExtensionsDesc =>
|
||||||
'Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.';
|
'Extend the app\'s capabilities with community extensions.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip1 =>
|
String get tutorialExtensionsTip1 =>
|
||||||
'Jelajahi tab Toko untuk menemukan ekstensi berguna';
|
'Browse the Store tab to discover useful extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip2 =>
|
String get tutorialExtensionsTip2 =>
|
||||||
'Tambahkan provider unduhan atau sumber pencarian baru';
|
'Add new download providers or search sources';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExtensionsTip3 =>
|
String get tutorialExtensionsTip3 =>
|
||||||
'Dapatkan lirik, metadata lebih baik, dan fitur lainnya';
|
'Get lyrics, enhanced metadata, and more features';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsTitle => 'Sesuaikan Pengalaman Anda';
|
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsDesc =>
|
String get tutorialSettingsDesc =>
|
||||||
'Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.';
|
'Personalize the app in Settings to match your preferences.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsTip1 =>
|
String get tutorialSettingsTip1 =>
|
||||||
'Ubah lokasi unduhan dan organisasi folder';
|
'Change download location and folder organization';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsTip2 =>
|
String get tutorialSettingsTip2 =>
|
||||||
'Atur kualitas audio dan preferensi format default';
|
'Set default audio quality and format preferences';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialSettingsTip3 => 'Sesuaikan tema dan tampilan aplikasi';
|
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialReadyMessage =>
|
String get tutorialReadyMessage =>
|
||||||
'Anda siap! Mulai unduh musik favorit Anda sekarang.';
|
'You\'re all set! Start downloading your favorite music now.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialExample => 'CONTOH';
|
String get tutorialExample => 'EXAMPLE';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryForceFullScan => 'Pindai Ulang Penuh';
|
String get libraryForceFullScan => 'Force Full Scan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryForceFullScanSubtitle =>
|
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||||
'Pindai ulang semua file, abaikan cache';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloads => 'Bersihkan Entri Unduhan Tidak Valid';
|
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloadsSubtitle =>
|
String get cleanupOrphanedDownloadsSubtitle =>
|
||||||
'Hapus entri riwayat untuk file yang tidak ada lagi';
|
'Remove history entries for files that no longer exist';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String cleanupOrphanedDownloadsResult(int count) {
|
String cleanupOrphanedDownloadsResult(int count) {
|
||||||
return 'Menghapus $count entri unduhan tidak valid dari riwayat';
|
return 'Removed $count orphaned entries from history';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloadsNone =>
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
'Tidak ada entri unduhan tidak valid';
|
|
||||||
|
@override
|
||||||
|
String get cacheTitle => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummaryTitle => 'Cache overview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummarySubtitle =>
|
||||||
|
'Clearing cache will not remove downloaded music files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEstimatedTotal(String size) {
|
||||||
|
return 'Estimated cache usage: $size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionStorage => 'Cached Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionMaintenance => 'Maintenance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectory => 'App cache directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectoryDesc =>
|
||||||
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectory => 'Temporary directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectoryDesc =>
|
||||||
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImage => 'Cover image cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImageDesc =>
|
||||||
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCover => 'Library cover cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCoverDesc =>
|
||||||
|
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeed => 'Explore feed cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeedDesc =>
|
||||||
|
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookup => 'Track lookup cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookupDesc =>
|
||||||
|
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedDesc =>
|
||||||
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheNoData => 'No cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
|
return '$size in $count files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeOnly(String size) {
|
||||||
|
return '$size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEntries(int count) {
|
||||||
|
return '$count entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearSuccess(String target) {
|
||||||
|
return 'Cleared: $target';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearConfirmMessage(String target) {
|
||||||
|
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||||
|
|
||||||
|
@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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
|
'Remove orphaned download history and missing library entries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
|
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefreshStats => 'Refresh stats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArt => 'Save Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSubtitle =>
|
||||||
|
'Re-embed metadata without re-downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCoverSaved(String fileName) {
|
||||||
|
return 'Cover art saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverNoSource => 'No cover art source available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackLyricsSaved(String fileName) {
|
||||||
|
return 'Lyrics saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackSaveFailed(String error) {
|
||||||
|
return 'Failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,6 +340,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get optionsSpotifyWarning =>
|
String get optionsSpotifyWarning =>
|
||||||
'Spotify は独自の API 認証情報が必要です。developer.spotify.com から取得できます。';
|
'Spotify は独自の API 認証情報が必要です。developer.spotify.com から取得できます。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
|
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => '拡張';
|
String get extensionsTitle => '拡張';
|
||||||
|
|
||||||
@@ -449,12 +453,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'サポート';
|
String get aboutSupport => 'サポート';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'コーヒーを買ってください';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi で開発をサポートします';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'アプリ';
|
String get aboutApp => 'アプリ';
|
||||||
|
|
||||||
@@ -487,6 +485,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get aboutDabMusicDesc =>
|
String get aboutDabMusicDesc =>
|
||||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaver => 'SpotiSaver';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaverDesc =>
|
||||||
|
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
|
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
|
||||||
@@ -1171,6 +1176,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'フォルダ構成';
|
String get folderOrganization => 'フォルダ構成';
|
||||||
|
|
||||||
@@ -1895,6 +1907,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
|
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeQualityNote =>
|
||||||
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
|
||||||
|
|
||||||
@@ -1907,6 +1923,28 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
|
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
|
'Artist folders use Album Artist when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => '形式を保存';
|
String get downloadSaveFormat => '形式を保存';
|
||||||
|
|
||||||
@@ -2140,6 +2178,12 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get recentTypePlaylist => 'プレイリスト';
|
String get recentTypePlaylist => 'プレイリスト';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentShowAllDownloads => 'Show All Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
return 'プレイリスト: $name';
|
return 'プレイリスト: $name';
|
||||||
@@ -2246,6 +2290,12 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCache => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryTitle => 'Local Library';
|
String get libraryTitle => 'Local Library';
|
||||||
|
|
||||||
@@ -2413,6 +2463,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterDateYear => 'This Year';
|
String get libraryFilterDateYear => 'This Year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortLatest => 'Latest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryFilterActive(int count) {
|
String libraryFilterActive(int count) {
|
||||||
return '$count filter(s) active';
|
return '$count filter(s) active';
|
||||||
@@ -2649,4 +2708,224 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTitle => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummaryTitle => 'Cache overview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummarySubtitle =>
|
||||||
|
'Clearing cache will not remove downloaded music files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEstimatedTotal(String size) {
|
||||||
|
return 'Estimated cache usage: $size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionStorage => 'Cached Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionMaintenance => 'Maintenance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectory => 'App cache directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectoryDesc =>
|
||||||
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectory => 'Temporary directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectoryDesc =>
|
||||||
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImage => 'Cover image cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImageDesc =>
|
||||||
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCover => 'Library cover cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCoverDesc =>
|
||||||
|
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeed => 'Explore feed cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeedDesc =>
|
||||||
|
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookup => 'Track lookup cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookupDesc =>
|
||||||
|
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedDesc =>
|
||||||
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheNoData => 'No cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
|
return '$size in $count files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeOnly(String size) {
|
||||||
|
return '$size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEntries(int count) {
|
||||||
|
return '$count entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearSuccess(String target) {
|
||||||
|
return 'Cleared: $target';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearConfirmMessage(String target) {
|
||||||
|
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||||
|
|
||||||
|
@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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
|
'Remove orphaned download history and missing library entries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
|
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefreshStats => 'Refresh stats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArt => 'Save Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSubtitle =>
|
||||||
|
'Re-embed metadata without re-downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCoverSaved(String fileName) {
|
||||||
|
return 'Cover art saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverNoSource => 'No cover art source available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackLyricsSaved(String fileName) {
|
||||||
|
return 'Lyrics saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackSaveFailed(String error) {
|
||||||
|
return 'Failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get appDescription =>
|
String get appDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Spotify 트랙을 Tidal, Qobuz, Amazon Music에서 무손실 음질로 다운로드하세요.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHome => 'Home';
|
String get navHome => 'Home';
|
||||||
@@ -34,32 +34,32 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get homeTitle => 'Home';
|
String get homeTitle => 'Home';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSearchHint => 'Paste Spotify URL or search...';
|
String get homeSearchHint => 'Spotify URL을 붙여 넣거나 검색';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String homeSearchHintExtension(String extensionName) {
|
String homeSearchHintExtension(String extensionName) {
|
||||||
return 'Search with $extensionName...';
|
return '$extensionName에서 검색';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
String get homeSupports => '지원 항목: 트랙, 앨범, 플레이리스트, 아티스트 URLs';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeRecent => 'Recent';
|
String get homeRecent => '최근 기록';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyTitle => 'History';
|
String get historyTitle => '기록';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String historyDownloading(int count) {
|
String historyDownloading(int count) {
|
||||||
return 'Downloading ($count)';
|
return '다운로드 중... $count';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyDownloaded => 'Downloaded';
|
String get historyDownloaded => '다운로드 목록';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get historyFilterAll => 'All';
|
String get historyFilterAll => 'All';
|
||||||
@@ -75,7 +75,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: '$count tracks',
|
other: '${count}tracks',
|
||||||
one: '1 track',
|
one: '1 track',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
@@ -245,14 +245,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get optionsAutoFallback => 'Auto Fallback';
|
String get optionsAutoFallback => 'Auto Fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsAutoFallbackSubtitle =>
|
String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도';
|
||||||
'Try other services if download fails';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
||||||
@@ -343,6 +342,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get optionsSpotifyWarning =>
|
String get optionsSpotifyWarning =>
|
||||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
|
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Extensions';
|
String get extensionsTitle => 'Extensions';
|
||||||
|
|
||||||
@@ -453,12 +456,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -491,6 +488,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get aboutDabMusicDesc =>
|
String get aboutDabMusicDesc =>
|
||||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaver => 'SpotiSaver';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaverDesc =>
|
||||||
|
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||||
@@ -1177,6 +1181,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'Folder Organization';
|
String get folderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -1907,6 +1918,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeQualityNote =>
|
||||||
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1919,6 +1934,28 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
|
'Artist folders use Album Artist when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2154,6 +2191,12 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get recentTypePlaylist => 'Playlist';
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentShowAllDownloads => 'Show All Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
return 'Playlist: $name';
|
return 'Playlist: $name';
|
||||||
@@ -2260,6 +2303,12 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCache => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryTitle => 'Local Library';
|
String get libraryTitle => 'Local Library';
|
||||||
|
|
||||||
@@ -2427,6 +2476,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterDateYear => 'This Year';
|
String get libraryFilterDateYear => 'This Year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortLatest => 'Latest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryFilterActive(int count) {
|
String libraryFilterActive(int count) {
|
||||||
return '$count filter(s) active';
|
return '$count filter(s) active';
|
||||||
@@ -2663,4 +2721,224 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTitle => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummaryTitle => 'Cache overview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummarySubtitle =>
|
||||||
|
'Clearing cache will not remove downloaded music files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEstimatedTotal(String size) {
|
||||||
|
return 'Estimated cache usage: $size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionStorage => 'Cached Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionMaintenance => 'Maintenance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectory => 'App cache directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectoryDesc =>
|
||||||
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectory => 'Temporary directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectoryDesc =>
|
||||||
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImage => 'Cover image cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImageDesc =>
|
||||||
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCover => 'Library cover cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCoverDesc =>
|
||||||
|
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeed => 'Explore feed cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeedDesc =>
|
||||||
|
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookup => 'Track lookup cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookupDesc =>
|
||||||
|
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedDesc =>
|
||||||
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheNoData => 'No cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
|
return '$size in $count files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeOnly(String size) {
|
||||||
|
return '$size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEntries(int count) {
|
||||||
|
return '$count entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearSuccess(String target) {
|
||||||
|
return 'Cleared: $target';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearConfirmMessage(String target) {
|
||||||
|
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||||
|
|
||||||
|
@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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
|
'Remove orphaned download history and missing library entries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
|
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefreshStats => 'Refresh stats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArt => 'Save Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSubtitle =>
|
||||||
|
'Re-embed metadata without re-downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCoverSaved(String fileName) {
|
||||||
|
return 'Cover art saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverNoSource => 'No cover art source available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackLyricsSaved(String fileName) {
|
||||||
|
return 'Lyrics saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackSaveFailed(String error) {
|
||||||
|
return 'Failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,6 +343,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get optionsSpotifyWarning =>
|
String get optionsSpotifyWarning =>
|
||||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
|
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Extensions';
|
String get extensionsTitle => 'Extensions';
|
||||||
|
|
||||||
@@ -453,12 +457,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -491,6 +489,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get aboutDabMusicDesc =>
|
String get aboutDabMusicDesc =>
|
||||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaver => 'SpotiSaver';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaverDesc =>
|
||||||
|
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||||
@@ -1177,6 +1182,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'Folder Organization';
|
String get folderOrganization => 'Folder Organization';
|
||||||
|
|
||||||
@@ -1907,6 +1919,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeQualityNote =>
|
||||||
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1919,6 +1935,28 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
|
'Artist folders use Album Artist when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2154,6 +2192,12 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get recentTypePlaylist => 'Playlist';
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentShowAllDownloads => 'Show All Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
return 'Playlist: $name';
|
return 'Playlist: $name';
|
||||||
@@ -2260,6 +2304,12 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCache => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryTitle => 'Local Library';
|
String get libraryTitle => 'Local Library';
|
||||||
|
|
||||||
@@ -2427,6 +2477,15 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterDateYear => 'This Year';
|
String get libraryFilterDateYear => 'This Year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortLatest => 'Latest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryFilterActive(int count) {
|
String libraryFilterActive(int count) {
|
||||||
return '$count filter(s) active';
|
return '$count filter(s) active';
|
||||||
@@ -2663,4 +2722,224 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTitle => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummaryTitle => 'Cache overview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummarySubtitle =>
|
||||||
|
'Clearing cache will not remove downloaded music files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEstimatedTotal(String size) {
|
||||||
|
return 'Estimated cache usage: $size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionStorage => 'Cached Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionMaintenance => 'Maintenance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectory => 'App cache directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectoryDesc =>
|
||||||
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectory => 'Temporary directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectoryDesc =>
|
||||||
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImage => 'Cover image cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImageDesc =>
|
||||||
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCover => 'Library cover cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCoverDesc =>
|
||||||
|
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeed => 'Explore feed cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeedDesc =>
|
||||||
|
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookup => 'Track lookup cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookupDesc =>
|
||||||
|
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedDesc =>
|
||||||
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheNoData => 'No cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
|
return '$size in $count files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeOnly(String size) {
|
||||||
|
return '$size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEntries(int count) {
|
||||||
|
return '$count entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearSuccess(String target) {
|
||||||
|
return 'Cleared: $target';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearConfirmMessage(String target) {
|
||||||
|
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||||
|
|
||||||
|
@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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
|
'Remove orphaned download history and missing library entries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
|
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefreshStats => 'Refresh stats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArt => 'Save Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSubtitle =>
|
||||||
|
'Re-embed metadata without re-downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCoverSaved(String fileName) {
|
||||||
|
return 'Cover art saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverNoSource => 'No cover art source available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackLyricsSaved(String fileName) {
|
||||||
|
return 'Lyrics saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackSaveFailed(String error) {
|
||||||
|
return 'Failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,6 +348,10 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get optionsSpotifyWarning =>
|
String get optionsSpotifyWarning =>
|
||||||
'Spotify\'ın senin API kimlik bilgilerine ihtiyacı var. Onları developer.spotify.com\'dan alabilirsin';
|
'Spotify\'ın senin API kimlik bilgilerine ihtiyacı var. Onları developer.spotify.com\'dan alabilirsin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
|
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Eklentiler';
|
String get extensionsTitle => 'Eklentiler';
|
||||||
|
|
||||||
@@ -460,12 +464,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Destek';
|
String get aboutSupport => 'Destek';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Bana bir kahve ısmarla';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi üzerinden uygulamayı destekle';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'Uygulama';
|
String get aboutApp => 'Uygulama';
|
||||||
|
|
||||||
@@ -498,6 +496,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get aboutDabMusicDesc =>
|
String get aboutDabMusicDesc =>
|
||||||
'En iyi Qobuz streaming API\'ı. Yüksek kalite indirmeler bunun sayesinde!';
|
'En iyi Qobuz streaming API\'ı. Yüksek kalite indirmeler bunun sayesinde!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaver => 'SpotiSaver';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaverDesc =>
|
||||||
|
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.';
|
'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.';
|
||||||
@@ -1184,6 +1189,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
return '$artist - $title';
|
return '$artist - $title';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get filenameShowAdvancedTagsDescription =>
|
||||||
|
'Enable formatted tags for track padding and date patterns';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get folderOrganization => 'Klasör Organizasyonu';
|
String get folderOrganization => 'Klasör Organizasyonu';
|
||||||
|
|
||||||
@@ -1922,6 +1934,10 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeQualityNote =>
|
||||||
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1934,6 +1950,28 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
|
'Artist folders use Album Artist when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -2169,6 +2207,12 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get recentTypePlaylist => 'Playlist';
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentShowAllDownloads => 'Show All Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
return 'Playlist: $name';
|
return 'Playlist: $name';
|
||||||
@@ -2275,6 +2319,12 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCache => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryTitle => 'Local Library';
|
String get libraryTitle => 'Local Library';
|
||||||
|
|
||||||
@@ -2442,6 +2492,15 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get libraryFilterDateYear => 'This Year';
|
String get libraryFilterDateYear => 'This Year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortLatest => 'Latest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryFilterActive(int count) {
|
String libraryFilterActive(int count) {
|
||||||
return '$count filter(s) active';
|
return '$count filter(s) active';
|
||||||
@@ -2678,4 +2737,224 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTitle => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummaryTitle => 'Cache overview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummarySubtitle =>
|
||||||
|
'Clearing cache will not remove downloaded music files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEstimatedTotal(String size) {
|
||||||
|
return 'Estimated cache usage: $size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionStorage => 'Cached Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionMaintenance => 'Maintenance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectory => 'App cache directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectoryDesc =>
|
||||||
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectory => 'Temporary directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectoryDesc =>
|
||||||
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImage => 'Cover image cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImageDesc =>
|
||||||
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCover => 'Library cover cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCoverDesc =>
|
||||||
|
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeed => 'Explore feed cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeedDesc =>
|
||||||
|
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookup => 'Track lookup cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookupDesc =>
|
||||||
|
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedDesc =>
|
||||||
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheNoData => 'No cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
|
return '$size in $count files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeOnly(String size) {
|
||||||
|
return '$size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEntries(int count) {
|
||||||
|
return '$count entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearSuccess(String target) {
|
||||||
|
return 'Cleared: $target';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearConfirmMessage(String target) {
|
||||||
|
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||||
|
|
||||||
|
@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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
|
'Remove orphaned download history and missing library entries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
|
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefreshStats => 'Refresh stats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArt => 'Save Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSubtitle =>
|
||||||
|
'Re-embed metadata without re-downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCoverSaved(String fileName) {
|
||||||
|
return 'Cover art saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverNoSource => 'No cover art source available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackLyricsSaved(String fileName) {
|
||||||
|
return 'Lyrics saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackSaveFailed(String error) {
|
||||||
|
return 'Failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,8 @@
|
|||||||
"@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"},
|
"@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"},
|
||||||
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
|
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
|
||||||
"@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"},
|
"@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"},
|
||||||
|
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
|
||||||
|
"@optionsSpotifyDeprecationWarning": {"description": "Warning about Spotify API deprecation"},
|
||||||
|
|
||||||
"extensionsTitle": "Extensions",
|
"extensionsTitle": "Extensions",
|
||||||
"@extensionsTitle": {"description": "Extensions page title"},
|
"@extensionsTitle": {"description": "Extensions page title"},
|
||||||
@@ -324,10 +326,6 @@
|
|||||||
"@aboutSocial": {"description": "Section for social links"},
|
"@aboutSocial": {"description": "Section for social links"},
|
||||||
"aboutSupport": "Support",
|
"aboutSupport": "Support",
|
||||||
"@aboutSupport": {"description": "Section for support/donation links"},
|
"@aboutSupport": {"description": "Section for support/donation links"},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {"description": "Donation link"},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {"description": "Subtitle for donation"},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {"description": "Section for app info"},
|
"@aboutApp": {"description": "Section for app info"},
|
||||||
"aboutVersion": "Version",
|
"aboutVersion": "Version",
|
||||||
@@ -346,6 +344,10 @@
|
|||||||
"@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"},
|
"@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"},
|
||||||
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
|
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
|
||||||
"@aboutDabMusicDesc": {"description": "Credit for DAB Music API"},
|
"@aboutDabMusicDesc": {"description": "Credit for DAB Music API"},
|
||||||
|
"aboutSpotiSaver": "SpotiSaver",
|
||||||
|
"@aboutSpotiSaver": {"description": "Name of SpotiSaver API service - DO NOT TRANSLATE"},
|
||||||
|
"aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!",
|
||||||
|
"@aboutSpotiSaverDesc": {"description": "Credit for SpotiSaver API"},
|
||||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||||
"@aboutAppDescription": {"description": "App description in header card"},
|
"@aboutAppDescription": {"description": "App description in header card"},
|
||||||
|
|
||||||
@@ -872,6 +874,14 @@
|
|||||||
"@filenameAvailablePlaceholders": {"description": "Label for placeholder list"},
|
"@filenameAvailablePlaceholders": {"description": "Label for placeholder list"},
|
||||||
"filenameHint": "{artist} - {title}",
|
"filenameHint": "{artist} - {title}",
|
||||||
"@filenameHint": {"description": "Default filename format hint"},
|
"@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": "Folder Organization",
|
||||||
"@folderOrganization": {"description": "Setting title - folder structure"},
|
"@folderOrganization": {"description": "Setting title - folder structure"},
|
||||||
@@ -1408,6 +1418,8 @@
|
|||||||
"@lossyFormatOpusSubtitle": {"description": "Opus format description"},
|
"@lossyFormatOpusSubtitle": {"description": "Opus format description"},
|
||||||
"qualityNote": "Actual quality depends on track availability from the service",
|
"qualityNote": "Actual quality depends on track availability from the service",
|
||||||
"@qualityNote": {"description": "Note about quality availability"},
|
"@qualityNote": {"description": "Note about quality availability"},
|
||||||
|
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||||
|
"@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"},
|
||||||
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
|
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
|
||||||
@@ -1417,6 +1429,18 @@
|
|||||||
"@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"},
|
"@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"},
|
||||||
"downloadAlbumFolderStructure": "Album Folder Structure",
|
"downloadAlbumFolderStructure": "Album Folder Structure",
|
||||||
"@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"},
|
"@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"},
|
||||||
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
|
"@downloadUseAlbumArtistForFolders": {"description": "Setting - choose whether artist folders use Album Artist or Track Artist"},
|
||||||
|
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available",
|
||||||
|
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
|
||||||
|
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
|
||||||
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"},
|
||||||
|
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
||||||
|
"@downloadUsePrimaryArtistOnly": {"description": "Setting - strip featured artists from folder name"},
|
||||||
|
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
|
||||||
|
"@downloadUsePrimaryArtistOnlyEnabled": {"description": "Subtitle when primary artist only is enabled"},
|
||||||
|
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
||||||
|
"@downloadUsePrimaryArtistOnlyDisabled": {"description": "Subtitle when primary artist only is disabled"},
|
||||||
"downloadSaveFormat": "Save Format",
|
"downloadSaveFormat": "Save Format",
|
||||||
"@downloadSaveFormat": {"description": "Setting - output file format"},
|
"@downloadSaveFormat": {"description": "Setting - output file format"},
|
||||||
"downloadSelectService": "Select Service",
|
"downloadSelectService": "Select Service",
|
||||||
@@ -1590,6 +1614,12 @@
|
|||||||
"@recentTypeSong": {"description": "Recent access item type - song/track"},
|
"@recentTypeSong": {"description": "Recent access item type - song/track"},
|
||||||
"recentTypePlaylist": "Playlist",
|
"recentTypePlaylist": "Playlist",
|
||||||
"@recentTypePlaylist": {"description": "Recent access item type - playlist"},
|
"@recentTypePlaylist": {"description": "Recent access item type - playlist"},
|
||||||
|
"recentEmpty": "No recent items yet",
|
||||||
|
"@recentEmpty": {"description": "Empty state text for recent access list"},
|
||||||
|
"recentShowAllDownloads": "Show All Downloads",
|
||||||
|
"@recentShowAllDownloads": {
|
||||||
|
"description": "Button label to unhide hidden downloads in recent access"
|
||||||
|
},
|
||||||
|
|
||||||
"recentPlaylistInfo": "Playlist: {name}",
|
"recentPlaylistInfo": "Playlist: {name}",
|
||||||
"@recentPlaylistInfo": {
|
"@recentPlaylistInfo": {
|
||||||
@@ -1700,6 +1730,10 @@
|
|||||||
"@settingsLocalLibrary": {"description": "Settings menu item - local library"},
|
"@settingsLocalLibrary": {"description": "Settings menu item - local library"},
|
||||||
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
|
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
|
||||||
"@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"},
|
"@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"},
|
||||||
|
"settingsCache": "Storage & Cache",
|
||||||
|
"@settingsCache": {"description": "Settings menu item - cache management"},
|
||||||
|
"settingsCacheSubtitle": "View size and clear cached data",
|
||||||
|
"@settingsCacheSubtitle": {"description": "Subtitle for cache management menu"},
|
||||||
"libraryTitle": "Local Library",
|
"libraryTitle": "Local Library",
|
||||||
"@libraryTitle": {"description": "Library settings page title"},
|
"@libraryTitle": {"description": "Library settings page title"},
|
||||||
"libraryStatus": "Library Status",
|
"libraryStatus": "Library Status",
|
||||||
@@ -1824,6 +1858,12 @@
|
|||||||
"@libraryFilterDateMonth": {"description": "Filter option - this month"},
|
"@libraryFilterDateMonth": {"description": "Filter option - this month"},
|
||||||
"libraryFilterDateYear": "This Year",
|
"libraryFilterDateYear": "This Year",
|
||||||
"@libraryFilterDateYear": {"description": "Filter option - this year"},
|
"@libraryFilterDateYear": {"description": "Filter option - this year"},
|
||||||
|
"libraryFilterSort": "Sort",
|
||||||
|
"@libraryFilterSort": {"description": "Filter section - sort order"},
|
||||||
|
"libraryFilterSortLatest": "Latest",
|
||||||
|
"@libraryFilterSortLatest": {"description": "Sort option - newest first"},
|
||||||
|
"libraryFilterSortOldest": "Oldest",
|
||||||
|
"@libraryFilterSortOldest": {"description": "Sort option - oldest first"},
|
||||||
"libraryFilterActive": "{count} filter(s) active",
|
"libraryFilterActive": "{count} filter(s) active",
|
||||||
"@libraryFilterActive": {
|
"@libraryFilterActive": {
|
||||||
"description": "Badge showing number of active filters",
|
"description": "Badge showing number of active filters",
|
||||||
@@ -2000,5 +2040,192 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cleanupOrphanedDownloadsNone": "No orphaned entries found",
|
"cleanupOrphanedDownloadsNone": "No orphaned entries found",
|
||||||
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"}
|
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"},
|
||||||
|
|
||||||
|
"cacheTitle": "Storage & Cache",
|
||||||
|
"@cacheTitle": {"description": "Cache management page title"},
|
||||||
|
"cacheSummaryTitle": "Cache overview",
|
||||||
|
"@cacheSummaryTitle": {"description": "Heading for cache summary card"},
|
||||||
|
"cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.",
|
||||||
|
"@cacheSummarySubtitle": {"description": "Helper text for cache summary card"},
|
||||||
|
"cacheEstimatedTotal": "Estimated cache usage: {size}",
|
||||||
|
"@cacheEstimatedTotal": {
|
||||||
|
"description": "Total cache size shown in summary",
|
||||||
|
"placeholders": {
|
||||||
|
"size": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheSectionStorage": "Cached Data",
|
||||||
|
"@cacheSectionStorage": {"description": "Section header for cache entries"},
|
||||||
|
"cacheSectionMaintenance": "Maintenance",
|
||||||
|
"@cacheSectionMaintenance": {"description": "Section header for cleanup actions"},
|
||||||
|
"cacheAppDirectory": "App cache directory",
|
||||||
|
"@cacheAppDirectory": {"description": "Cache item title for app cache directory"},
|
||||||
|
"cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.",
|
||||||
|
"@cacheAppDirectoryDesc": {"description": "Description of what app cache directory contains"},
|
||||||
|
"cacheTempDirectory": "Temporary directory",
|
||||||
|
"@cacheTempDirectory": {"description": "Cache item title for temporary files directory"},
|
||||||
|
"cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.",
|
||||||
|
"@cacheTempDirectoryDesc": {"description": "Description of what temporary directory contains"},
|
||||||
|
"cacheCoverImage": "Cover image cache",
|
||||||
|
"@cacheCoverImage": {"description": "Cache item title for persistent cover images"},
|
||||||
|
"cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.",
|
||||||
|
"@cacheCoverImageDesc": {"description": "Description of what cover image cache contains"},
|
||||||
|
"cacheLibraryCover": "Library cover cache",
|
||||||
|
"@cacheLibraryCover": {"description": "Cache item title for local library cover art images"},
|
||||||
|
"cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.",
|
||||||
|
"@cacheLibraryCoverDesc": {"description": "Description of what library cover cache contains"},
|
||||||
|
"cacheExploreFeed": "Explore feed cache",
|
||||||
|
"@cacheExploreFeed": {"description": "Cache item title for explore home feed cache"},
|
||||||
|
"cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.",
|
||||||
|
"@cacheExploreFeedDesc": {"description": "Description of what explore feed cache contains"},
|
||||||
|
"cacheTrackLookup": "Track lookup cache",
|
||||||
|
"@cacheTrackLookup": {"description": "Cache item title for track ID lookup cache"},
|
||||||
|
"cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.",
|
||||||
|
"@cacheTrackLookupDesc": {"description": "Description of what track lookup cache contains"},
|
||||||
|
"cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.",
|
||||||
|
"@cacheCleanupUnusedDesc": {"description": "Description of what cleanup unused data does"},
|
||||||
|
"cacheNoData": "No cached data",
|
||||||
|
"@cacheNoData": {"description": "Label when cache category has no data"},
|
||||||
|
"cacheSizeWithFiles": "{size} in {count} files",
|
||||||
|
"@cacheSizeWithFiles": {
|
||||||
|
"description": "Cache size and file count",
|
||||||
|
"placeholders": {
|
||||||
|
"size": {"type": "String"},
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheSizeOnly": "{size}",
|
||||||
|
"@cacheSizeOnly": {
|
||||||
|
"description": "Cache size only",
|
||||||
|
"placeholders": {
|
||||||
|
"size": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheEntries": "{count} entries",
|
||||||
|
"@cacheEntries": {
|
||||||
|
"description": "Track cache entry count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheClearSuccess": "Cleared: {target}",
|
||||||
|
"@cacheClearSuccess": {
|
||||||
|
"description": "Snackbar after clearing selected cache",
|
||||||
|
"placeholders": {
|
||||||
|
"target": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheClearConfirmTitle": "Clear cache?",
|
||||||
|
"@cacheClearConfirmTitle": {"description": "Dialog title before clearing one cache category"},
|
||||||
|
"cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.",
|
||||||
|
"@cacheClearConfirmMessage": {
|
||||||
|
"description": "Dialog message before clearing selected cache",
|
||||||
|
"placeholders": {
|
||||||
|
"target": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheClearAllConfirmTitle": "Clear all cache?",
|
||||||
|
"@cacheClearAllConfirmTitle": {"description": "Dialog title before clearing all caches"},
|
||||||
|
"cacheClearAllConfirmMessage": "This will clear all cache categories on this page. Downloaded music files will not be deleted.",
|
||||||
|
"@cacheClearAllConfirmMessage": {"description": "Dialog message before clearing all caches"},
|
||||||
|
"cacheClearAll": "Clear all cache",
|
||||||
|
"@cacheClearAll": {"description": "Button label to clear all caches"},
|
||||||
|
"cacheCleanupUnused": "Cleanup unused data",
|
||||||
|
"@cacheCleanupUnused": {"description": "Action title for cleaning unused entries"},
|
||||||
|
"cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries",
|
||||||
|
"@cacheCleanupUnusedSubtitle": {"description": "Subtitle for cleanup unused data action"},
|
||||||
|
"cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries",
|
||||||
|
"@cacheCleanupResult": {
|
||||||
|
"description": "Snackbar after unused data cleanup",
|
||||||
|
"placeholders": {
|
||||||
|
"downloadCount": {"type": "int"},
|
||||||
|
"libraryCount": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheRefreshStats": "Refresh stats",
|
||||||
|
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"},
|
||||||
|
|
||||||
|
"trackSaveCoverArt": "Save Cover Art",
|
||||||
|
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
|
||||||
|
"trackSaveCoverArtSubtitle": "Save album art as .jpg file",
|
||||||
|
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
|
||||||
|
"trackSaveLyrics": "Save Lyrics (.lrc)",
|
||||||
|
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
||||||
|
"trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file",
|
||||||
|
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
||||||
|
"trackSaveLyricsProgress": "Saving lyrics...",
|
||||||
|
"@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"},
|
||||||
|
"trackReEnrich": "Re-enrich Metadata",
|
||||||
|
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
|
||||||
|
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
|
||||||
|
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
|
||||||
|
"trackReEnrichOnlineSubtitle": "Search metadata online and embed into file",
|
||||||
|
"@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"},
|
||||||
|
"trackEditMetadata": "Edit Metadata",
|
||||||
|
"@trackEditMetadata": {"description": "Menu action - edit embedded metadata"},
|
||||||
|
"trackCoverSaved": "Cover art saved to {fileName}",
|
||||||
|
"@trackCoverSaved": {
|
||||||
|
"description": "Snackbar after cover art saved",
|
||||||
|
"placeholders": {
|
||||||
|
"fileName": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackCoverNoSource": "No cover art source available",
|
||||||
|
"@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"},
|
||||||
|
"trackLyricsSaved": "Lyrics saved to {fileName}",
|
||||||
|
"@trackLyricsSaved": {
|
||||||
|
"description": "Snackbar after lyrics saved",
|
||||||
|
"placeholders": {
|
||||||
|
"fileName": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackReEnrichProgress": "Re-enriching metadata...",
|
||||||
|
"@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"},
|
||||||
|
"trackReEnrichSearching": "Searching metadata online...",
|
||||||
|
"@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"},
|
||||||
|
"trackReEnrichSuccess": "Metadata re-enriched successfully",
|
||||||
|
"@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"},
|
||||||
|
"trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed",
|
||||||
|
"@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"},
|
||||||
|
"trackSaveFailed": "Failed: {error}",
|
||||||
|
"@trackSaveFailed": {
|
||||||
|
"description": "Snackbar when save operation fails",
|
||||||
|
"placeholders": {
|
||||||
|
"error": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"trackConvertFormat": "Convert Format",
|
||||||
|
"@trackConvertFormat": {"description": "Menu item - convert audio format"},
|
||||||
|
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
||||||
|
"@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"},
|
||||||
|
"trackConvertTitle": "Convert Audio",
|
||||||
|
"@trackConvertTitle": {"description": "Title of convert bottom sheet"},
|
||||||
|
"trackConvertTargetFormat": "Target Format",
|
||||||
|
"@trackConvertTargetFormat": {"description": "Label for format selection"},
|
||||||
|
"trackConvertBitrate": "Bitrate",
|
||||||
|
"@trackConvertBitrate": {"description": "Label for bitrate selection"},
|
||||||
|
"trackConvertConfirmTitle": "Confirm Conversion",
|
||||||
|
"@trackConvertConfirmTitle": {"description": "Confirmation dialog title"},
|
||||||
|
"trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.",
|
||||||
|
"@trackConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {"type": "String"},
|
||||||
|
"targetFormat": {"type": "String"},
|
||||||
|
"bitrate": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertConverting": "Converting audio...",
|
||||||
|
"@trackConvertConverting": {"description": "Snackbar while converting"},
|
||||||
|
"trackConvertSuccess": "Converted to {format} successfully",
|
||||||
|
"@trackConvertSuccess": {
|
||||||
|
"description": "Snackbar after successful conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"format": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertFailed": "Conversion failed",
|
||||||
|
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@@ -11,41 +12,101 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
|||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final runtimeProfile = await _resolveRuntimeProfile();
|
||||||
await CoverCacheManager.initialize();
|
_configureImageCache(runtimeProfile);
|
||||||
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
|
|
||||||
|
|
||||||
await Future.wait([
|
|
||||||
NotificationService().initialize(),
|
|
||||||
ShareIntentService().initialize(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
child: const _EagerInitialization(
|
child: _EagerInitialization(
|
||||||
child: SpotiFLACApp(),
|
child: SpotiFLACApp(
|
||||||
|
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = 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
|
/// Widget to eagerly initialize providers that need to load data on startup
|
||||||
class _EagerInitialization extends ConsumerStatefulWidget {
|
class _EagerInitialization extends ConsumerStatefulWidget {
|
||||||
const _EagerInitialization({required this.child});
|
const _EagerInitialization({required this.child});
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState();
|
ConsumerState<_EagerInitialization> createState() =>
|
||||||
|
_EagerInitializationState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_initializeAppServices();
|
||||||
_initializeExtensions();
|
_initializeExtensions();
|
||||||
ref.read(downloadHistoryProvider);
|
ref.read(downloadHistoryProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _initializeAppServices() async {
|
||||||
|
try {
|
||||||
|
await CoverCacheManager.initialize();
|
||||||
|
await Future.wait([
|
||||||
|
NotificationService().initialize(),
|
||||||
|
ShareIntentService().initialize(),
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to initialize app services: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _initializeExtensions() async {
|
Future<void> _initializeExtensions() async {
|
||||||
try {
|
try {
|
||||||
final appDir = await getApplicationDocumentsDirectory();
|
final appDir = await getApplicationDocumentsDirectory();
|
||||||
@@ -55,7 +116,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
|||||||
await Directory(extensionsDir).create(recursive: true);
|
await Directory(extensionsDir).create(recursive: true);
|
||||||
await Directory(dataDir).create(recursive: true);
|
await Directory(dataDir).create(recursive: true);
|
||||||
|
|
||||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
await ref
|
||||||
|
.read(extensionProvider.notifier)
|
||||||
|
.initialize(extensionsDir, dataDir);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to initialize extensions: $e');
|
debugPrint('Failed to initialize extensions: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class DownloadItem {
|
|||||||
final DownloadStatus status;
|
final DownloadStatus status;
|
||||||
final double progress;
|
final double progress;
|
||||||
final double speedMBps;
|
final double speedMBps;
|
||||||
|
final int bytesReceived; // Bytes downloaded so far (for unknown size downloads)
|
||||||
final String? filePath;
|
final String? filePath;
|
||||||
final String? error;
|
final String? error;
|
||||||
final DownloadErrorType? errorType;
|
final DownloadErrorType? errorType;
|
||||||
@@ -41,6 +42,7 @@ class DownloadItem {
|
|||||||
this.status = DownloadStatus.queued,
|
this.status = DownloadStatus.queued,
|
||||||
this.progress = 0.0,
|
this.progress = 0.0,
|
||||||
this.speedMBps = 0.0,
|
this.speedMBps = 0.0,
|
||||||
|
this.bytesReceived = 0,
|
||||||
this.filePath,
|
this.filePath,
|
||||||
this.error,
|
this.error,
|
||||||
this.errorType,
|
this.errorType,
|
||||||
@@ -55,6 +57,7 @@ class DownloadItem {
|
|||||||
DownloadStatus? status,
|
DownloadStatus? status,
|
||||||
double? progress,
|
double? progress,
|
||||||
double? speedMBps,
|
double? speedMBps,
|
||||||
|
int? bytesReceived,
|
||||||
String? filePath,
|
String? filePath,
|
||||||
String? error,
|
String? error,
|
||||||
DownloadErrorType? errorType,
|
DownloadErrorType? errorType,
|
||||||
@@ -68,6 +71,7 @@ class DownloadItem {
|
|||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
progress: progress ?? this.progress,
|
progress: progress ?? this.progress,
|
||||||
speedMBps: speedMBps ?? this.speedMBps,
|
speedMBps: speedMBps ?? this.speedMBps,
|
||||||
|
bytesReceived: bytesReceived ?? this.bytesReceived,
|
||||||
filePath: filePath ?? this.filePath,
|
filePath: filePath ?? this.filePath,
|
||||||
error: error ?? this.error,
|
error: error ?? this.error,
|
||||||
errorType: errorType ?? this.errorType,
|
errorType: errorType ?? this.errorType,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
|||||||
DownloadStatus.queued,
|
DownloadStatus.queued,
|
||||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||||
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
|
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
|
||||||
filePath: json['filePath'] as String?,
|
filePath: json['filePath'] as String?,
|
||||||
error: json['error'] as String?,
|
error: json['error'] as String?,
|
||||||
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
||||||
@@ -30,6 +31,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
|||||||
'status': _$DownloadStatusEnumMap[instance.status]!,
|
'status': _$DownloadStatusEnumMap[instance.status]!,
|
||||||
'progress': instance.progress,
|
'progress': instance.progress,
|
||||||
'speedMBps': instance.speedMBps,
|
'speedMBps': instance.speedMBps,
|
||||||
|
'bytesReceived': instance.bytesReceived,
|
||||||
'filePath': instance.filePath,
|
'filePath': instance.filePath,
|
||||||
'error': instance.error,
|
'error': instance.error,
|
||||||
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class AppSettings {
|
|||||||
final String updateChannel;
|
final String updateChannel;
|
||||||
final bool hasSearchedBefore;
|
final bool hasSearchedBefore;
|
||||||
final String folderOrganization;
|
final String folderOrganization;
|
||||||
|
final bool useAlbumArtistForFolders;
|
||||||
|
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
|
||||||
|
final bool filterContributingArtistsInAlbumArtist;
|
||||||
final String historyViewMode;
|
final String historyViewMode;
|
||||||
final String historyFilterMode;
|
final String historyFilterMode;
|
||||||
final bool askQualityBeforeDownload;
|
final bool askQualityBeforeDownload;
|
||||||
@@ -34,18 +37,36 @@ class AppSettings {
|
|||||||
final bool showExtensionStore;
|
final bool showExtensionStore;
|
||||||
final String locale;
|
final String locale;
|
||||||
final String lyricsMode;
|
final String lyricsMode;
|
||||||
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
final String
|
||||||
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||||
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
|
final bool
|
||||||
final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
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
|
// Local Library Settings
|
||||||
final bool localLibraryEnabled; // Enable local library scanning
|
final bool localLibraryEnabled; // Enable local library scanning
|
||||||
final String localLibraryPath; // Path to scan for audio files
|
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
|
// 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({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -63,6 +84,9 @@ class AppSettings {
|
|||||||
this.updateChannel = 'stable',
|
this.updateChannel = 'stable',
|
||||||
this.hasSearchedBefore = false,
|
this.hasSearchedBefore = false,
|
||||||
this.folderOrganization = 'none',
|
this.folderOrganization = 'none',
|
||||||
|
this.useAlbumArtistForFolders = true,
|
||||||
|
this.usePrimaryArtistOnly = false,
|
||||||
|
this.filterContributingArtistsInAlbumArtist = false,
|
||||||
this.historyViewMode = 'grid',
|
this.historyViewMode = 'grid',
|
||||||
this.historyFilterMode = 'all',
|
this.historyFilterMode = 'all',
|
||||||
this.askQualityBeforeDownload = true,
|
this.askQualityBeforeDownload = true,
|
||||||
@@ -88,6 +112,12 @@ class AppSettings {
|
|||||||
this.localLibraryShowDuplicates = true,
|
this.localLibraryShowDuplicates = true,
|
||||||
// Tutorial default
|
// Tutorial default
|
||||||
this.hasCompletedTutorial = false,
|
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({
|
AppSettings copyWith({
|
||||||
@@ -106,6 +136,9 @@ class AppSettings {
|
|||||||
String? updateChannel,
|
String? updateChannel,
|
||||||
bool? hasSearchedBefore,
|
bool? hasSearchedBefore,
|
||||||
String? folderOrganization,
|
String? folderOrganization,
|
||||||
|
bool? useAlbumArtistForFolders,
|
||||||
|
bool? usePrimaryArtistOnly,
|
||||||
|
bool? filterContributingArtistsInAlbumArtist,
|
||||||
String? historyViewMode,
|
String? historyViewMode,
|
||||||
String? historyFilterMode,
|
String? historyFilterMode,
|
||||||
bool? askQualityBeforeDownload,
|
bool? askQualityBeforeDownload,
|
||||||
@@ -132,6 +165,12 @@ class AppSettings {
|
|||||||
bool? localLibraryShowDuplicates,
|
bool? localLibraryShowDuplicates,
|
||||||
// Tutorial
|
// Tutorial
|
||||||
bool? hasCompletedTutorial,
|
bool? hasCompletedTutorial,
|
||||||
|
// Lyrics providers
|
||||||
|
List<String>? lyricsProviders,
|
||||||
|
bool? lyricsIncludeTranslationNetease,
|
||||||
|
bool? lyricsIncludeRomanizationNetease,
|
||||||
|
bool? lyricsMultiPersonWordByWord,
|
||||||
|
String? musixmatchLanguage,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -149,16 +188,27 @@ class AppSettings {
|
|||||||
updateChannel: updateChannel ?? this.updateChannel,
|
updateChannel: updateChannel ?? this.updateChannel,
|
||||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
|
useAlbumArtistForFolders:
|
||||||
|
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
|
||||||
|
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
|
||||||
|
filterContributingArtistsInAlbumArtist:
|
||||||
|
filterContributingArtistsInAlbumArtist ??
|
||||||
|
this.filterContributingArtistsInAlbumArtist,
|
||||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
askQualityBeforeDownload:
|
||||||
|
askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
useCustomSpotifyCredentials:
|
||||||
|
useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||||
metadataSource: metadataSource ?? this.metadataSource,
|
metadataSource: metadataSource ?? this.metadataSource,
|
||||||
enableLogging: enableLogging ?? this.enableLogging,
|
enableLogging: enableLogging ?? this.enableLogging,
|
||||||
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
|
useExtensionProviders:
|
||||||
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
|
useExtensionProviders ?? this.useExtensionProviders,
|
||||||
|
searchProvider: clearSearchProvider
|
||||||
|
? null
|
||||||
|
: (searchProvider ?? this.searchProvider),
|
||||||
separateSingles: separateSingles ?? this.separateSingles,
|
separateSingles: separateSingles ?? this.separateSingles,
|
||||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||||
@@ -166,14 +216,25 @@ class AppSettings {
|
|||||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||||
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
autoExportFailedDownloads:
|
||||||
|
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||||
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
|
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
|
||||||
// Local Library
|
// Local Library
|
||||||
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
|
||||||
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
|
||||||
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
localLibraryShowDuplicates:
|
||||||
|
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||||
// Tutorial
|
// Tutorial
|
||||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
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',
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
@@ -49,46 +53,68 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
localLibraryShowDuplicates:
|
localLibraryShowDuplicates:
|
||||||
json['localLibraryShowDuplicates'] as bool? ?? true,
|
json['localLibraryShowDuplicates'] as bool? ?? true,
|
||||||
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
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) =>
|
Map<String, dynamic> _$AppSettingsToJson(
|
||||||
<String, dynamic>{
|
AppSettings instance,
|
||||||
'defaultService': instance.defaultService,
|
) => <String, dynamic>{
|
||||||
'audioQuality': instance.audioQuality,
|
'defaultService': instance.defaultService,
|
||||||
'filenameFormat': instance.filenameFormat,
|
'audioQuality': instance.audioQuality,
|
||||||
'downloadDirectory': instance.downloadDirectory,
|
'filenameFormat': instance.filenameFormat,
|
||||||
'storageMode': instance.storageMode,
|
'downloadDirectory': instance.downloadDirectory,
|
||||||
'downloadTreeUri': instance.downloadTreeUri,
|
'storageMode': instance.storageMode,
|
||||||
'autoFallback': instance.autoFallback,
|
'downloadTreeUri': instance.downloadTreeUri,
|
||||||
'embedLyrics': instance.embedLyrics,
|
'autoFallback': instance.autoFallback,
|
||||||
'maxQualityCover': instance.maxQualityCover,
|
'embedLyrics': instance.embedLyrics,
|
||||||
'isFirstLaunch': instance.isFirstLaunch,
|
'maxQualityCover': instance.maxQualityCover,
|
||||||
'concurrentDownloads': instance.concurrentDownloads,
|
'isFirstLaunch': instance.isFirstLaunch,
|
||||||
'checkForUpdates': instance.checkForUpdates,
|
'concurrentDownloads': instance.concurrentDownloads,
|
||||||
'updateChannel': instance.updateChannel,
|
'checkForUpdates': instance.checkForUpdates,
|
||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'updateChannel': instance.updateChannel,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'historyViewMode': instance.historyViewMode,
|
'folderOrganization': instance.folderOrganization,
|
||||||
'historyFilterMode': instance.historyFilterMode,
|
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
||||||
'spotifyClientId': instance.spotifyClientId,
|
'filterContributingArtistsInAlbumArtist':
|
||||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
instance.filterContributingArtistsInAlbumArtist,
|
||||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
'historyViewMode': instance.historyViewMode,
|
||||||
'metadataSource': instance.metadataSource,
|
'historyFilterMode': instance.historyFilterMode,
|
||||||
'enableLogging': instance.enableLogging,
|
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||||
'useExtensionProviders': instance.useExtensionProviders,
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
'searchProvider': instance.searchProvider,
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
'separateSingles': instance.separateSingles,
|
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||||
'albumFolderStructure': instance.albumFolderStructure,
|
'metadataSource': instance.metadataSource,
|
||||||
'showExtensionStore': instance.showExtensionStore,
|
'enableLogging': instance.enableLogging,
|
||||||
'locale': instance.locale,
|
'useExtensionProviders': instance.useExtensionProviders,
|
||||||
'lyricsMode': instance.lyricsMode,
|
'searchProvider': instance.searchProvider,
|
||||||
'tidalHighFormat': instance.tidalHighFormat,
|
'separateSingles': instance.separateSingles,
|
||||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
'albumFolderStructure': instance.albumFolderStructure,
|
||||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
'showExtensionStore': instance.showExtensionStore,
|
||||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
'locale': instance.locale,
|
||||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
'lyricsMode': instance.lyricsMode,
|
||||||
'localLibraryPath': instance.localLibraryPath,
|
'tidalHighFormat': instance.tidalHighFormat,
|
||||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||||
};
|
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||||
|
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||||
|
'localLibraryPath': instance.localLibraryPath,
|
||||||
|
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||||
|
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||||
|
'lyricsProviders': instance.lyricsProviders,
|
||||||
|
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
|
||||||
|
'lyricsIncludeRomanizationNetease': instance.lyricsIncludeRomanizationNetease,
|
||||||
|
'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord,
|
||||||
|
'musixmatchLanguage': instance.musixmatchLanguage,
|
||||||
|
};
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class Extension {
|
|||||||
final List<QualityOption> qualityOptions;
|
final List<QualityOption> qualityOptions;
|
||||||
final bool hasMetadataProvider;
|
final bool hasMetadataProvider;
|
||||||
final bool hasDownloadProvider;
|
final bool hasDownloadProvider;
|
||||||
|
final bool hasLyricsProvider;
|
||||||
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||||
final SearchBehavior? searchBehavior;
|
final SearchBehavior? searchBehavior;
|
||||||
final URLHandler? urlHandler;
|
final URLHandler? urlHandler;
|
||||||
@@ -49,6 +50,7 @@ class Extension {
|
|||||||
this.qualityOptions = const [],
|
this.qualityOptions = const [],
|
||||||
this.hasMetadataProvider = false,
|
this.hasMetadataProvider = false,
|
||||||
this.hasDownloadProvider = false,
|
this.hasDownloadProvider = false,
|
||||||
|
this.hasLyricsProvider = false,
|
||||||
this.skipMetadataEnrichment = false,
|
this.skipMetadataEnrichment = false,
|
||||||
this.searchBehavior,
|
this.searchBehavior,
|
||||||
this.urlHandler,
|
this.urlHandler,
|
||||||
@@ -78,6 +80,7 @@ class Extension {
|
|||||||
.toList() ?? [],
|
.toList() ?? [],
|
||||||
hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false,
|
hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false,
|
||||||
hasDownloadProvider: json['has_download_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,
|
skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false,
|
||||||
searchBehavior: json['search_behavior'] != null
|
searchBehavior: json['search_behavior'] != null
|
||||||
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
|
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
|
||||||
@@ -111,6 +114,7 @@ class Extension {
|
|||||||
List<QualityOption>? qualityOptions,
|
List<QualityOption>? qualityOptions,
|
||||||
bool? hasMetadataProvider,
|
bool? hasMetadataProvider,
|
||||||
bool? hasDownloadProvider,
|
bool? hasDownloadProvider,
|
||||||
|
bool? hasLyricsProvider,
|
||||||
bool? skipMetadataEnrichment,
|
bool? skipMetadataEnrichment,
|
||||||
SearchBehavior? searchBehavior,
|
SearchBehavior? searchBehavior,
|
||||||
URLHandler? urlHandler,
|
URLHandler? urlHandler,
|
||||||
@@ -134,6 +138,7 @@ class Extension {
|
|||||||
qualityOptions: qualityOptions ?? this.qualityOptions,
|
qualityOptions: qualityOptions ?? this.qualityOptions,
|
||||||
hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider,
|
hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider,
|
||||||
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
|
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
|
||||||
|
hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider,
|
||||||
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
||||||
searchBehavior: searchBehavior ?? this.searchBehavior,
|
searchBehavior: searchBehavior ?? this.searchBehavior,
|
||||||
urlHandler: urlHandler ?? this.urlHandler,
|
urlHandler: urlHandler ?? this.urlHandler,
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ import 'dart:io';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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/history_database.dart';
|
||||||
import 'package:spotiflac_android/services/library_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/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
final _log = AppLogger('LocalLibrary');
|
final _log = AppLogger('LocalLibrary');
|
||||||
|
|
||||||
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||||
|
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
||||||
|
final _prefs = SharedPreferences.getInstance();
|
||||||
|
|
||||||
class LocalLibraryState {
|
class LocalLibraryState {
|
||||||
final List<LocalLibraryItem> items;
|
final List<LocalLibraryItem> items;
|
||||||
@@ -22,9 +26,10 @@ class LocalLibraryState {
|
|||||||
final int scanErrorCount;
|
final int scanErrorCount;
|
||||||
final bool scanWasCancelled;
|
final bool scanWasCancelled;
|
||||||
final DateTime? lastScannedAt;
|
final DateTime? lastScannedAt;
|
||||||
final Set<String> _isrcSet;
|
final int excludedDownloadedCount;
|
||||||
final Set<String> _trackKeySet;
|
final Set<String> _trackKeySet;
|
||||||
final Map<String, LocalLibraryItem> _byIsrc;
|
final Map<String, LocalLibraryItem> _byIsrc;
|
||||||
|
final Map<String, LocalLibraryItem> _byTrackKey;
|
||||||
|
|
||||||
LocalLibraryState({
|
LocalLibraryState({
|
||||||
this.items = const [],
|
this.items = const [],
|
||||||
@@ -36,18 +41,23 @@ class LocalLibraryState {
|
|||||||
this.scanErrorCount = 0,
|
this.scanErrorCount = 0,
|
||||||
this.scanWasCancelled = false,
|
this.scanWasCancelled = false,
|
||||||
this.lastScannedAt,
|
this.lastScannedAt,
|
||||||
}) : _isrcSet = items
|
this.excludedDownloadedCount = 0,
|
||||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
Set<String>? trackKeySet,
|
||||||
.map((item) => item.isrc!)
|
Map<String, LocalLibraryItem>? byIsrc,
|
||||||
.toSet(),
|
Map<String, LocalLibraryItem>? byTrackKey,
|
||||||
_trackKeySet = items.map((item) => item.matchKey).toSet(),
|
}) : _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(),
|
||||||
_byIsrc = Map.fromEntries(
|
_byIsrc =
|
||||||
items
|
byIsrc ??
|
||||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
Map.fromEntries(
|
||||||
.map((item) => MapEntry(item.isrc!, item)),
|
items
|
||||||
);
|
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||||
|
.map((item) => MapEntry(item.isrc!, item)),
|
||||||
|
),
|
||||||
|
_byTrackKey =
|
||||||
|
byTrackKey ??
|
||||||
|
Map.fromEntries(items.map((item) => MapEntry(item.matchKey, item)));
|
||||||
|
|
||||||
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
|
bool hasIsrc(String isrc) => _byIsrc.containsKey(isrc);
|
||||||
|
|
||||||
bool hasTrack(String trackName, String artistName) {
|
bool hasTrack(String trackName, String artistName) {
|
||||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||||
@@ -58,7 +68,7 @@ class LocalLibraryState {
|
|||||||
|
|
||||||
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
|
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
|
||||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||||
return items.where((item) => item.matchKey == key).firstOrNull;
|
return _byTrackKey[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
|
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
|
||||||
@@ -81,9 +91,13 @@ class LocalLibraryState {
|
|||||||
int? scanErrorCount,
|
int? scanErrorCount,
|
||||||
bool? scanWasCancelled,
|
bool? scanWasCancelled,
|
||||||
DateTime? lastScannedAt,
|
DateTime? lastScannedAt,
|
||||||
|
int? excludedDownloadedCount,
|
||||||
}) {
|
}) {
|
||||||
|
final nextItems = items ?? this.items;
|
||||||
|
final keepDerivedIndex = identical(nextItems, this.items);
|
||||||
|
|
||||||
return LocalLibraryState(
|
return LocalLibraryState(
|
||||||
items: items ?? this.items,
|
items: nextItems,
|
||||||
isScanning: isScanning ?? this.isScanning,
|
isScanning: isScanning ?? this.isScanning,
|
||||||
scanProgress: scanProgress ?? this.scanProgress,
|
scanProgress: scanProgress ?? this.scanProgress,
|
||||||
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
||||||
@@ -92,6 +106,11 @@ class LocalLibraryState {
|
|||||||
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
|
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
|
||||||
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
|
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
|
||||||
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
||||||
|
excludedDownloadedCount:
|
||||||
|
excludedDownloadedCount ?? this.excludedDownloadedCount,
|
||||||
|
trackKeySet: keepDerivedIndex ? _trackKeySet : null,
|
||||||
|
byIsrc: keepDerivedIndex ? _byIsrc : null,
|
||||||
|
byTrackKey: keepDerivedIndex ? _byTrackKey : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,9 +118,13 @@ class LocalLibraryState {
|
|||||||
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||||
final LibraryDatabase _db = LibraryDatabase.instance;
|
final LibraryDatabase _db = LibraryDatabase.instance;
|
||||||
final HistoryDatabase _historyDb = HistoryDatabase.instance;
|
final HistoryDatabase _historyDb = HistoryDatabase.instance;
|
||||||
|
final NotificationService _notificationService = NotificationService();
|
||||||
|
static const _progressPollingInterval = Duration(milliseconds: 800);
|
||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
bool _scanCancelRequested = false;
|
bool _scanCancelRequested = false;
|
||||||
|
int _progressPollingErrorCount = 0;
|
||||||
|
bool _isProgressPollingInFlight = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
LocalLibraryState build() {
|
LocalLibraryState build() {
|
||||||
@@ -120,24 +143,36 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_isLoaded = true;
|
_isLoaded = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final jsonList = await _db.getAll();
|
final dbItemsFuture = _db.getAll();
|
||||||
|
final prefsFuture = _prefs;
|
||||||
|
final jsonList = await dbItemsFuture;
|
||||||
final items = jsonList
|
final items = jsonList
|
||||||
.map((e) => LocalLibraryItem.fromJson(e))
|
.map((e) => LocalLibraryItem.fromJson(e))
|
||||||
.toList();
|
.toList(growable: false);
|
||||||
|
|
||||||
DateTime? lastScannedAt;
|
DateTime? lastScannedAt;
|
||||||
|
var excludedDownloadedCount = 0;
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await prefsFuture;
|
||||||
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
||||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
||||||
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
||||||
}
|
}
|
||||||
|
excludedDownloadedCount =
|
||||||
|
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to load lastScannedAt: $e');
|
_log.w('Failed to load lastScannedAt: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
state = state.copyWith(items: items, lastScannedAt: lastScannedAt);
|
state = state.copyWith(
|
||||||
_log.i('Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt');
|
items: items,
|
||||||
|
lastScannedAt: lastScannedAt,
|
||||||
|
excludedDownloadedCount: excludedDownloadedCount,
|
||||||
|
);
|
||||||
|
_log.i(
|
||||||
|
'Loaded ${items.length} items from library database, lastScannedAt: '
|
||||||
|
'$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount',
|
||||||
|
);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
_log.e('Failed to load library from database: $e', e, stack);
|
_log.e('Failed to load library from database: $e', e, stack);
|
||||||
}
|
}
|
||||||
@@ -148,14 +183,71 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
await _loadFromDatabase();
|
await _loadFromDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startScan(String folderPath, {bool forceFullScan = false}) async {
|
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,
|
||||||
|
}) async {
|
||||||
if (state.isScanning) {
|
if (state.isScanning) {
|
||||||
_log.w('Scan already in progress');
|
_log.w('Scan already in progress');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_scanCancelRequested = false;
|
_scanCancelRequested = false;
|
||||||
_log.i('Starting library scan: $folderPath (incremental: ${!forceFullScan})');
|
_log.i(
|
||||||
|
'Starting library scan: $folderPath (incremental: ${!forceFullScan})',
|
||||||
|
);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isScanning: true,
|
isScanning: true,
|
||||||
scanProgress: 0,
|
scanProgress: 0,
|
||||||
@@ -165,10 +257,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
scanErrorCount: 0,
|
scanErrorCount: 0,
|
||||||
scanWasCancelled: false,
|
scanWasCancelled: false,
|
||||||
);
|
);
|
||||||
|
await _showScanProgressNotification(
|
||||||
|
progress: 0,
|
||||||
|
scannedFiles: 0,
|
||||||
|
totalFiles: 0,
|
||||||
|
currentFile: null,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final cacheDir = await getApplicationCacheDirectory();
|
final appSupportDir = await getApplicationSupportDirectory();
|
||||||
final coverCacheDir = '${cacheDir.path}/library_covers';
|
final coverCacheDir = '${appSupportDir.path}/library_covers';
|
||||||
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
|
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
|
||||||
_log.i('Cover cache directory set to: $coverCacheDir');
|
_log.i('Cover cache directory set to: $coverCacheDir');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -180,9 +278,27 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
try {
|
try {
|
||||||
final isSaf = folderPath.startsWith('content://');
|
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 downloadedPaths = await _historyDb.getAllFilePaths();
|
||||||
_log.i('Excluding ${downloadedPaths.length} downloaded files from library scan');
|
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 ${allHistoryPaths.length} downloaded files from library scan '
|
||||||
|
'(${downloadedPathKeys.length} path keys)',
|
||||||
|
);
|
||||||
|
|
||||||
if (forceFullScan) {
|
if (forceFullScan) {
|
||||||
// Full scan path - ignores existing data
|
// Full scan path - ignores existing data
|
||||||
@@ -191,6 +307,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
: await PlatformBridge.scanLibraryFolder(folderPath);
|
: await PlatformBridge.scanLibraryFolder(folderPath);
|
||||||
if (_scanCancelRequested) {
|
if (_scanCancelRequested) {
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||||
|
await _showScanCancelledNotification();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +316,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
for (final json in results) {
|
for (final json in results) {
|
||||||
final filePath = json['filePath'] as String?;
|
final filePath = json['filePath'] as String?;
|
||||||
// Skip files that are already in download history
|
// Skip files that are already in download history
|
||||||
if (filePath != null && downloadedPaths.contains(filePath)) {
|
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
|
||||||
skippedDownloads++;
|
skippedDownloads++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -217,6 +334,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||||
|
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||||
_log.d('Saved lastScannedAt: $now');
|
_log.d('Saved lastScannedAt: $now');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to save lastScannedAt: $e');
|
_log.w('Failed to save lastScannedAt: $e');
|
||||||
@@ -228,13 +346,24 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
scanProgress: 100,
|
scanProgress: 100,
|
||||||
lastScannedAt: now,
|
lastScannedAt: now,
|
||||||
scanWasCancelled: false,
|
scanWasCancelled: false,
|
||||||
|
excludedDownloadedCount: skippedDownloads,
|
||||||
);
|
);
|
||||||
|
|
||||||
_log.i('Full scan complete: ${items.length} tracks found');
|
_log.i(
|
||||||
|
'Full scan complete: ${items.length} tracks found, '
|
||||||
|
'$skippedDownloads already in downloads',
|
||||||
|
);
|
||||||
|
await _showScanCompleteNotification(
|
||||||
|
totalTracks: items.length,
|
||||||
|
excludedDownloadedCount: skippedDownloads,
|
||||||
|
errorCount: state.scanErrorCount,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Incremental scan path - only scans new/modified files
|
// Incremental scan path - only scans new/modified files
|
||||||
final existingFiles = await _db.getFileModTimes();
|
final existingFiles = await _db.getFileModTimes();
|
||||||
_log.i('Incremental scan: ${existingFiles.length} existing files in database');
|
_log.i(
|
||||||
|
'Incremental scan: ${existingFiles.length} existing files in database',
|
||||||
|
);
|
||||||
|
|
||||||
final backfilledModTimes = await _backfillLegacyFileModTimes(
|
final backfilledModTimes = await _backfillLegacyFileModTimes(
|
||||||
isSaf: isSaf,
|
isSaf: isSaf,
|
||||||
@@ -262,64 +391,79 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
|
|
||||||
if (_scanCancelRequested) {
|
if (_scanCancelRequested) {
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||||
|
await _showScanCancelledNotification();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse incremental scan result
|
// Parse incremental scan result
|
||||||
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
|
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
|
||||||
final scannedList = (result['files'] as List<dynamic>?)
|
final scannedList =
|
||||||
?? (result['scanned'] as List<dynamic>?)
|
(result['files'] as List<dynamic>?) ??
|
||||||
?? [];
|
(result['scanned'] as List<dynamic>?) ??
|
||||||
final deletedPaths = (result['removedUris'] as List<dynamic>?)
|
[];
|
||||||
?.map((e) => e as String)
|
final deletedPaths =
|
||||||
.toList()
|
(result['removedUris'] as List<dynamic>?)
|
||||||
?? (result['deletedPaths'] as List<dynamic>?)
|
|
||||||
?.map((e) => e as String)
|
?.map((e) => e as String)
|
||||||
.toList()
|
.toList() ??
|
||||||
?? [];
|
(result['deletedPaths'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
final skippedCount = result['skippedCount'] as int? ?? 0;
|
final skippedCount = result['skippedCount'] as int? ?? 0;
|
||||||
final totalFiles = result['totalFiles'] as int? ?? 0;
|
final totalFiles = result['totalFiles'] as int? ?? 0;
|
||||||
|
|
||||||
_log.i('Incremental result: ${scannedList.length} scanned, '
|
_log.i(
|
||||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total');
|
'Incremental result: ${scannedList.length} scanned, '
|
||||||
|
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||||
|
);
|
||||||
|
|
||||||
|
final currentByPath = <String, LocalLibraryItem>{
|
||||||
|
for (final item in state.items) item.filePath: item,
|
||||||
|
};
|
||||||
|
|
||||||
// Upsert new/modified items (excluding downloaded files)
|
// Upsert new/modified items (excluding downloaded files)
|
||||||
|
final updatedItems = <LocalLibraryItem>[];
|
||||||
|
int skippedDownloads = 0;
|
||||||
if (scannedList.isNotEmpty) {
|
if (scannedList.isNotEmpty) {
|
||||||
final items = <LocalLibraryItem>[];
|
|
||||||
int skippedDownloads = 0;
|
|
||||||
for (final json in scannedList) {
|
for (final json in scannedList) {
|
||||||
final map = json as Map<String, dynamic>;
|
final map = json as Map<String, dynamic>;
|
||||||
final filePath = map['filePath'] as String?;
|
final filePath = map['filePath'] as String?;
|
||||||
// Skip files that are already in download history
|
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
|
||||||
if (filePath != null && downloadedPaths.contains(filePath)) {
|
|
||||||
skippedDownloads++;
|
skippedDownloads++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
items.add(LocalLibraryItem.fromJson(map));
|
final item = LocalLibraryItem.fromJson(map);
|
||||||
|
updatedItems.add(item);
|
||||||
|
currentByPath[item.filePath] = item;
|
||||||
}
|
}
|
||||||
if (items.isNotEmpty) {
|
if (updatedItems.isNotEmpty) {
|
||||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
await _db.upsertBatch(updatedItems.map((e) => e.toJson()).toList());
|
||||||
_log.i('Upserted ${items.length} items');
|
_log.i('Upserted ${updatedItems.length} items');
|
||||||
}
|
}
|
||||||
if (skippedDownloads > 0) {
|
if (skippedDownloads > 0) {
|
||||||
_log.i('Skipped $skippedDownloads files already in download history');
|
_log.i(
|
||||||
|
'Skipped $skippedDownloads files already in download history',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removed items
|
// Delete removed items
|
||||||
if (deletedPaths.isNotEmpty) {
|
if (deletedPaths.isNotEmpty) {
|
||||||
final deleteCount = await _db.deleteByPaths(deletedPaths);
|
final deleteCount = await _db.deleteByPaths(deletedPaths);
|
||||||
|
for (final path in deletedPaths) {
|
||||||
|
currentByPath.remove(path);
|
||||||
|
}
|
||||||
_log.i('Deleted $deleteCount items from database');
|
_log.i('Deleted $deleteCount items from database');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload all items from database to get complete list
|
final items = currentByPath.values.toList(growable: false)
|
||||||
final allItems = await _db.getAll();
|
..sort(_compareLibraryItems);
|
||||||
final items = allItems.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||||
|
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||||
_log.d('Saved lastScannedAt: $now');
|
_log.d('Saved lastScannedAt: $now');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to save lastScannedAt: $e');
|
_log.w('Failed to save lastScannedAt: $e');
|
||||||
@@ -331,14 +475,24 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
scanProgress: 100,
|
scanProgress: 100,
|
||||||
lastScannedAt: now,
|
lastScannedAt: now,
|
||||||
scanWasCancelled: false,
|
scanWasCancelled: false,
|
||||||
|
excludedDownloadedCount: skippedDownloads,
|
||||||
);
|
);
|
||||||
|
|
||||||
_log.i('Incremental scan complete: ${items.length} total tracks '
|
_log.i(
|
||||||
'(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)');
|
'Incremental scan complete: ${items.length} total tracks '
|
||||||
|
'(${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) {
|
} catch (e, stack) {
|
||||||
_log.e('Library scan failed: $e', e, stack);
|
_log.e('Library scan failed: $e', e, stack);
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: false);
|
state = state.copyWith(isScanning: false, scanWasCancelled: false);
|
||||||
|
await _showScanFailedNotification(e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
_stopProgressPolling();
|
_stopProgressPolling();
|
||||||
}
|
}
|
||||||
@@ -346,28 +500,65 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
|
|
||||||
void _startProgressPolling() {
|
void _startProgressPolling() {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
|
||||||
|
if (_isProgressPollingInFlight) return;
|
||||||
|
_isProgressPollingInFlight = true;
|
||||||
try {
|
try {
|
||||||
final progress = await PlatformBridge.getLibraryScanProgress();
|
final progress = await PlatformBridge.getLibraryScanProgress();
|
||||||
|
final nextProgress =
|
||||||
state = state.copyWith(
|
(progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
||||||
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
|
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
||||||
scanCurrentFile: progress['current_file'] as String?,
|
0.0,
|
||||||
scanTotalFiles: progress['total_files'] as int? ?? 0,
|
100.0,
|
||||||
scannedFiles: progress['scanned_files'] as int? ?? 0,
|
|
||||||
scanErrorCount: progress['error_count'] as int? ?? 0,
|
|
||||||
);
|
);
|
||||||
|
final currentFile = progress['current_file'] as String?;
|
||||||
|
final totalFiles = progress['total_files'] as int? ?? 0;
|
||||||
|
final scannedFiles = progress['scanned_files'] as int? ?? 0;
|
||||||
|
final errorCount = progress['error_count'] as int? ?? 0;
|
||||||
|
|
||||||
|
final shouldUpdateState =
|
||||||
|
state.scanProgress != normalizedProgress ||
|
||||||
|
state.scanCurrentFile != currentFile ||
|
||||||
|
state.scanTotalFiles != totalFiles ||
|
||||||
|
state.scannedFiles != scannedFiles ||
|
||||||
|
state.scanErrorCount != errorCount;
|
||||||
|
|
||||||
|
if (shouldUpdateState) {
|
||||||
|
state = state.copyWith(
|
||||||
|
scanProgress: normalizedProgress,
|
||||||
|
scanCurrentFile: currentFile,
|
||||||
|
scanTotalFiles: totalFiles,
|
||||||
|
scannedFiles: scannedFiles,
|
||||||
|
scanErrorCount: errorCount,
|
||||||
|
);
|
||||||
|
await _showScanProgressNotification(
|
||||||
|
progress: normalizedProgress,
|
||||||
|
scannedFiles: scannedFiles,
|
||||||
|
totalFiles: totalFiles,
|
||||||
|
currentFile: currentFile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (progress['is_complete'] == true) {
|
if (progress['is_complete'] == true) {
|
||||||
_stopProgressPolling();
|
_stopProgressPolling();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
_progressPollingErrorCount = 0;
|
||||||
|
} catch (e) {
|
||||||
|
_progressPollingErrorCount++;
|
||||||
|
if (_progressPollingErrorCount <= 3) {
|
||||||
|
_log.w('Library scan progress polling failed: $e');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isProgressPollingInFlight = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _stopProgressPolling() {
|
void _stopProgressPolling() {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = null;
|
_progressTimer = null;
|
||||||
|
_progressPollingErrorCount = 0;
|
||||||
|
_isProgressPollingInFlight = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancelScan() async {
|
Future<void> cancelScan() async {
|
||||||
@@ -378,6 +569,75 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
await PlatformBridge.cancelLibraryScan();
|
await PlatformBridge.cancelLibraryScan();
|
||||||
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
state = state.copyWith(isScanning: false, scanWasCancelled: true);
|
||||||
_stopProgressPolling();
|
_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 {
|
Future<int> cleanupMissingFiles() async {
|
||||||
@@ -394,6 +654,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_lastScannedAtKey);
|
await prefs.remove(_lastScannedAtKey);
|
||||||
|
await prefs.remove(_excludedDownloadedCountKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to clear lastScannedAt: $e');
|
_log.w('Failed to clear lastScannedAt: $e');
|
||||||
}
|
}
|
||||||
@@ -421,7 +682,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
return state.getByIsrc(isrc);
|
return state.getByIsrc(isrc);
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalLibraryItem? findExisting({String? isrc, String? trackName, String? artistName}) {
|
LocalLibraryItem? findExisting({
|
||||||
|
String? isrc,
|
||||||
|
String? trackName,
|
||||||
|
String? artistName,
|
||||||
|
}) {
|
||||||
if (isrc != null && isrc.isNotEmpty) {
|
if (isrc != null && isrc.isNotEmpty) {
|
||||||
final byIsrc = state.getByIsrc(isrc);
|
final byIsrc = state.getByIsrc(isrc);
|
||||||
if (byIsrc != null) return byIsrc;
|
if (byIsrc != null) return byIsrc;
|
||||||
@@ -443,6 +708,23 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
return await _db.getCount();
|
return await _db.getCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _compareLibraryItems(LocalLibraryItem a, LocalLibraryItem b) {
|
||||||
|
final artistA = (a.albumArtist ?? a.artistName).toLowerCase();
|
||||||
|
final artistB = (b.albumArtist ?? b.artistName).toLowerCase();
|
||||||
|
final artistCompare = artistA.compareTo(artistB);
|
||||||
|
if (artistCompare != 0) return artistCompare;
|
||||||
|
|
||||||
|
final albumCompare = a.albumName.toLowerCase().compareTo(
|
||||||
|
b.albumName.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (albumCompare != 0) return albumCompare;
|
||||||
|
|
||||||
|
final discCompare = (a.discNumber ?? 0).compareTo(b.discNumber ?? 0);
|
||||||
|
if (discCompare != 0) return discCompare;
|
||||||
|
|
||||||
|
return (a.trackNumber ?? 0).compareTo(b.trackNumber ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, int>> _backfillLegacyFileModTimes({
|
Future<Map<String, int>> _backfillLegacyFileModTimes({
|
||||||
required bool isSaf,
|
required bool isSaf,
|
||||||
required Map<String, int> existingFiles,
|
required Map<String, int> existingFiles,
|
||||||
@@ -469,7 +751,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
if (_scanCancelRequested) {
|
if (_scanCancelRequested) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
final end = (i + chunkSize < uris.length) ? i + chunkSize : uris.length;
|
final end = (i + chunkSize < uris.length)
|
||||||
|
? i + chunkSize
|
||||||
|
: uris.length;
|
||||||
final chunk = uris.sublist(i, end);
|
final chunk = uris.sublist(i, end);
|
||||||
final chunkResult = await PlatformBridge.getSafFileModTimes(chunk);
|
final chunkResult = await PlatformBridge.getSafFileModTimes(chunk);
|
||||||
backfilled.addAll(chunkResult);
|
backfilled.addAll(chunkResult);
|
||||||
@@ -481,17 +765,34 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final paths = legacyPaths
|
||||||
|
.where((path) => !path.startsWith('content://'))
|
||||||
|
.toList(growable: false);
|
||||||
|
const chunkSize = 24;
|
||||||
final backfilled = <String, int>{};
|
final backfilled = <String, int>{};
|
||||||
for (final path in legacyPaths) {
|
|
||||||
if (_scanCancelRequested || path.startsWith('content://')) {
|
for (var i = 0; i < paths.length; i += chunkSize) {
|
||||||
continue;
|
if (_scanCancelRequested) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
try {
|
final end = (i + chunkSize < paths.length) ? i + chunkSize : paths.length;
|
||||||
final stat = await File(path).stat();
|
final chunk = paths.sublist(i, end);
|
||||||
if (stat.type == FileSystemEntityType.file) {
|
final chunkEntries = await Future.wait<MapEntry<String, int>?>(
|
||||||
backfilled[path] = stat.modified.millisecondsSinceEpoch;
|
chunk.map((path) async {
|
||||||
|
try {
|
||||||
|
final stat = await File(path).stat();
|
||||||
|
if (stat.type == FileSystemEntityType.file) {
|
||||||
|
return MapEntry(path, stat.modified.millisecondsSinceEpoch);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (final entry in chunkEntries) {
|
||||||
|
if (entry != null) {
|
||||||
|
backfilled[entry.key] = entry.value;
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
}
|
||||||
}
|
}
|
||||||
return backfilled;
|
return backfilled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ const _settingsKey = 'app_settings';
|
|||||||
const _migrationVersionKey = 'settings_migration_version';
|
const _migrationVersionKey = 'settings_migration_version';
|
||||||
const _currentMigrationVersion = 2;
|
const _currentMigrationVersion = 2;
|
||||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||||
|
final _log = AppLogger('SettingsProvider');
|
||||||
|
|
||||||
class SettingsNotifier extends Notifier<AppSettings> {
|
class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||||
|
bool _isSavingSettings = false;
|
||||||
|
bool _saveQueued = false;
|
||||||
|
String? _pendingSettingsJson;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AppSettings build() {
|
AppSettings build() {
|
||||||
@@ -35,6 +39,23 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_applySpotifyCredentials();
|
_applySpotifyCredentials();
|
||||||
|
|
||||||
LogBuffer.loggingEnabled = state.enableLogging;
|
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 {
|
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||||
@@ -61,20 +82,43 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveSettings() async {
|
Future<void> _saveSettings() async {
|
||||||
final prefs = await _prefs;
|
final settingsToSave = state.copyWith(spotifyClientSecret: '');
|
||||||
final settingsToSave = state.copyWith(
|
_pendingSettingsJson = jsonEncode(settingsToSave.toJson());
|
||||||
spotifyClientSecret: '',
|
|
||||||
);
|
if (_isSavingSettings) {
|
||||||
await prefs.setString(_settingsKey, jsonEncode(settingsToSave.toJson()));
|
_saveQueued = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isSavingSettings = true;
|
||||||
|
try {
|
||||||
|
final prefs = await _prefs;
|
||||||
|
do {
|
||||||
|
final jsonToWrite = _pendingSettingsJson;
|
||||||
|
_saveQueued = false;
|
||||||
|
if (jsonToWrite != null) {
|
||||||
|
await prefs.setString(_settingsKey, jsonToWrite);
|
||||||
|
}
|
||||||
|
} while (_saveQueued);
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to save settings: $e');
|
||||||
|
} finally {
|
||||||
|
_isSavingSettings = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
|
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
|
||||||
final storedSecret = await _secureStorage.read(key: _spotifyClientSecretKey);
|
final storedSecret = await _secureStorage.read(
|
||||||
|
key: _spotifyClientSecretKey,
|
||||||
|
);
|
||||||
final prefsSecret = state.spotifyClientSecret;
|
final prefsSecret = state.spotifyClientSecret;
|
||||||
|
|
||||||
if ((storedSecret == null || storedSecret.isEmpty) &&
|
if ((storedSecret == null || storedSecret.isEmpty) &&
|
||||||
prefsSecret.isNotEmpty) {
|
prefsSecret.isNotEmpty) {
|
||||||
await _secureStorage.write(key: _spotifyClientSecretKey, value: prefsSecret);
|
await _secureStorage.write(
|
||||||
|
key: _spotifyClientSecretKey,
|
||||||
|
value: prefsSecret,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
|
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
|
||||||
@@ -161,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) {
|
void setMaxQualityCover(bool enabled) {
|
||||||
state = state.copyWith(maxQualityCover: enabled);
|
state = state.copyWith(maxQualityCover: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -172,7 +246,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setConcurrentDownloads(int count) {
|
void setConcurrentDownloads(int count) {
|
||||||
final clamped = count.clamp(1, 3);
|
final clamped = count.clamp(1, 5);
|
||||||
state = state.copyWith(concurrentDownloads: clamped);
|
state = state.copyWith(concurrentDownloads: clamped);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
@@ -199,6 +273,21 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setUseAlbumArtistForFolders(bool enabled) {
|
||||||
|
state = state.copyWith(useAlbumArtistForFolders: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUsePrimaryArtistOnly(bool enabled) {
|
||||||
|
state = state.copyWith(usePrimaryArtistOnly: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFilterContributingArtistsInAlbumArtist(bool enabled) {
|
||||||
|
state = state.copyWith(filterContributingArtistsInAlbumArtist: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setHistoryViewMode(String mode) {
|
void setHistoryViewMode(String mode) {
|
||||||
state = state.copyWith(historyViewMode: mode);
|
state = state.copyWith(historyViewMode: mode);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -225,7 +314,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
Future<void> setSpotifyCredentials(
|
||||||
|
String clientId,
|
||||||
|
String clientSecret,
|
||||||
|
) async {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
spotifyClientId: clientId,
|
spotifyClientId: clientId,
|
||||||
spotifyClientSecret: clientSecret,
|
spotifyClientSecret: clientSecret,
|
||||||
@@ -236,10 +328,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearSpotifyCredentials() async {
|
Future<void> clearSpotifyCredentials() async {
|
||||||
state = state.copyWith(
|
state = state.copyWith(spotifyClientId: '', spotifyClientSecret: '');
|
||||||
spotifyClientId: '',
|
|
||||||
spotifyClientSecret: '',
|
|
||||||
);
|
|
||||||
await _storeSpotifyClientSecret('');
|
await _storeSpotifyClientSecret('');
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
_applySpotifyCredentials();
|
_applySpotifyCredentials();
|
||||||
@@ -301,7 +390,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setUseAllFilesAccess(bool enabled) {
|
void setUseAllFilesAccess(bool enabled) {
|
||||||
state = state.copyWith(useAllFilesAccess: enabled);
|
state = state.copyWith(useAllFilesAccess: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ class TrackState {
|
|||||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
||||||
final bool hasSearchText; // For back button handling
|
final bool hasSearchText; // For back button handling
|
||||||
final bool isShowingRecentAccess; // For recent access mode
|
final bool isShowingRecentAccess; // For recent access mode
|
||||||
final String? searchExtensionId; // Extension ID used for current search results
|
final String?
|
||||||
final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
searchExtensionId; // Extension ID used for current search results
|
||||||
|
final String?
|
||||||
|
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
@@ -52,7 +54,12 @@ class TrackState {
|
|||||||
this.selectedSearchFilter,
|
this.selectedSearchFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty) || (searchAlbums != null && searchAlbums!.isNotEmpty) || (searchPlaylists != null && searchPlaylists!.isNotEmpty);
|
bool get hasContent =>
|
||||||
|
tracks.isNotEmpty ||
|
||||||
|
artistAlbums != null ||
|
||||||
|
(searchArtists != null && searchArtists!.isNotEmpty) ||
|
||||||
|
(searchAlbums != null && searchAlbums!.isNotEmpty) ||
|
||||||
|
(searchPlaylists != null && searchPlaylists!.isNotEmpty);
|
||||||
|
|
||||||
TrackState copyWith({
|
TrackState copyWith({
|
||||||
List<Track>? tracks,
|
List<Track>? tracks,
|
||||||
@@ -95,9 +102,12 @@ class TrackState {
|
|||||||
searchAlbums: searchAlbums ?? this.searchAlbums,
|
searchAlbums: searchAlbums ?? this.searchAlbums,
|
||||||
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
|
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
|
||||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||||
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
|
isShowingRecentAccess:
|
||||||
|
isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||||
searchExtensionId: searchExtensionId,
|
searchExtensionId: searchExtensionId,
|
||||||
selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter),
|
selectedSearchFilter: clearSelectedSearchFilter
|
||||||
|
? null
|
||||||
|
: (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,6 +188,7 @@ class SearchPlaylist {
|
|||||||
|
|
||||||
class TrackNotifier extends Notifier<TrackState> {
|
class TrackNotifier extends Notifier<TrackState> {
|
||||||
int _currentRequestId = 0;
|
int _currentRequestId = 0;
|
||||||
|
static const int _maxPreWarmTracksPerRequest = 80;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TrackState build() {
|
TrackState build() {
|
||||||
@@ -205,13 +216,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
// Check if we got valid data
|
// Check if we got valid data
|
||||||
if (result != null && result['type'] == 'track' && result['track'] != null) {
|
if (result != null &&
|
||||||
|
result['type'] == 'track' &&
|
||||||
|
result['track'] != null) {
|
||||||
final trackData = result['track'] as Map<String, dynamic>;
|
final trackData = result['track'] as Map<String, dynamic>;
|
||||||
final name = trackData['name']?.toString() ?? '';
|
final name = trackData['name']?.toString() ?? '';
|
||||||
if (name.isNotEmpty) {
|
if (name.isNotEmpty) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if (result != null && (result['type'] == 'album' || result['type'] == 'playlist')) {
|
} else if (result != null &&
|
||||||
|
(result['type'] == 'album' || result['type'] == 'playlist')) {
|
||||||
break;
|
break;
|
||||||
} else if (result != null && result['type'] == 'artist') {
|
} else if (result != null && result['type'] == 'artist') {
|
||||||
break;
|
break;
|
||||||
@@ -245,15 +259,27 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
searchExtensionId: extensionId,
|
searchExtensionId: extensionId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) {
|
} else if ((type == 'album' || type == 'playlist') &&
|
||||||
|
result['tracks'] != null) {
|
||||||
final trackList = result['tracks'] as List<dynamic>;
|
final trackList = result['tracks'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
final tracks = trackList
|
||||||
|
.map(
|
||||||
|
(t) => _parseSearchTrack(
|
||||||
|
t as Map<String, dynamic>,
|
||||||
|
source: extensionId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumId: result['album']?['id'] as String?,
|
albumId: result['album']?['id'] as String?,
|
||||||
albumName: result['name'] as String? ?? result['album']?['name'] as String?,
|
albumName:
|
||||||
playlistName: type == 'playlist' ? result['name'] as String? : null,
|
result['name'] as String? ??
|
||||||
|
result['album']?['name'] as String?,
|
||||||
|
playlistName: type == 'playlist'
|
||||||
|
? result['name'] as String?
|
||||||
|
: null,
|
||||||
coverUrl: result['cover_url'] as String?,
|
coverUrl: result['cover_url'] as String?,
|
||||||
searchExtensionId: extensionId,
|
searchExtensionId: extensionId,
|
||||||
);
|
);
|
||||||
@@ -261,17 +287,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'artist' && result['artist'] != null) {
|
} else if (type == 'artist' && result['artist'] != null) {
|
||||||
final artistData = result['artist'] as Map<String, dynamic>;
|
final artistData = result['artist'] as Map<String, dynamic>;
|
||||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
final albums = albumsList
|
||||||
|
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
final topTracksList =
|
||||||
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||||
|
final topTracks = topTracksList
|
||||||
|
.map(
|
||||||
|
(t) => _parseSearchTrack(
|
||||||
|
t as Map<String, dynamic>,
|
||||||
|
source: extensionId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: [],
|
tracks: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
artistId: artistData['id'] as String?,
|
artistId: artistData['id'] as String?,
|
||||||
artistName: artistData['name'] as String?,
|
artistName: artistData['name'] as String?,
|
||||||
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
|
coverUrl:
|
||||||
|
artistData['image_url'] as String? ??
|
||||||
|
artistData['images'] as String?,
|
||||||
headerImageUrl: artistData['header_image'] as String?,
|
headerImageUrl: artistData['header_image'] as String?,
|
||||||
monthlyListeners: artistData['listeners'] as int?,
|
monthlyListeners: artistData['listeners'] as int?,
|
||||||
artistAlbums: albums,
|
artistAlbums: albums,
|
||||||
@@ -306,7 +344,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'album') {
|
} else if (type == 'album') {
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -316,9 +356,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
);
|
);
|
||||||
_preWarmCacheForTracks(tracks);
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'playlist') {
|
} else if (type == 'playlist') {
|
||||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
final playlistInfo =
|
||||||
|
metadata['playlist_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -329,7 +372,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'artist') {
|
} else if (type == 'artist') {
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
final albums = albumsList
|
||||||
|
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: [],
|
tracks: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -357,7 +402,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
try {
|
try {
|
||||||
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
|
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
|
||||||
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(url);
|
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(
|
||||||
|
url,
|
||||||
|
);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final spotifyUrl = conversion['spotify_url'] as String?;
|
final spotifyUrl = conversion['spotify_url'] as String?;
|
||||||
@@ -365,7 +412,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
|
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
|
||||||
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
|
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(spotifyUrl);
|
final metadata =
|
||||||
|
await PlatformBridge.getSpotifyMetadataWithFallback(
|
||||||
|
spotifyUrl,
|
||||||
|
);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
@@ -378,8 +428,13 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
return;
|
return;
|
||||||
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
|
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
|
||||||
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
|
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
|
||||||
final deezerParsed = await PlatformBridge.parseDeezerUrl(deezerUrl);
|
final deezerParsed = await PlatformBridge.parseDeezerUrl(
|
||||||
final metadata = await PlatformBridge.getDeezerMetadata('track', deezerParsed['id'] as String);
|
deezerUrl,
|
||||||
|
);
|
||||||
|
final metadata = await PlatformBridge.getDeezerMetadata(
|
||||||
|
'track',
|
||||||
|
deezerParsed['id'] as String,
|
||||||
|
);
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
@@ -399,7 +454,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
// For album/artist/playlist, not yet supported
|
// For album/artist/playlist, not yet supported
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: 'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
|
error:
|
||||||
|
'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -432,7 +488,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'album') {
|
} else if (type == 'album') {
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -444,7 +502,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'playlist') {
|
} else if (type == 'playlist') {
|
||||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -456,7 +516,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} else if (type == 'artist') {
|
} else if (type == 'artist') {
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
final albums = albumsList
|
||||||
|
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: [],
|
tracks: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -468,17 +530,29 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
state = TrackState(
|
||||||
|
isLoading: false,
|
||||||
|
error: e.toString(),
|
||||||
|
hasSearchText: state.hasSearchText,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(String query, {String? metadataSource, String? filterOverride}) async {
|
Future<void> search(
|
||||||
|
String query, {
|
||||||
|
String? metadataSource,
|
||||||
|
String? filterOverride,
|
||||||
|
}) async {
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve selected filter during loading
|
// Preserve selected filter during loading
|
||||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||||
|
|
||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
|
state = TrackState(
|
||||||
|
isLoading: true,
|
||||||
|
hasSearchText: state.hasSearchText,
|
||||||
|
selectedSearchFilter: currentFilter,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
@@ -505,7 +579,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
if (useExtensions) {
|
if (useExtensions) {
|
||||||
try {
|
try {
|
||||||
_log.d('Calling extension search API...');
|
_log.d('Calling extension search API...');
|
||||||
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
|
final extResults = await PlatformBridge.searchTracksWithExtensions(
|
||||||
|
query,
|
||||||
|
limit: 20,
|
||||||
|
);
|
||||||
_log.i('Extensions returned ${extResults.length} tracks');
|
_log.i('Extensions returned ${extResults.length} tracks');
|
||||||
|
|
||||||
for (final t in extResults) {
|
for (final t in extResults) {
|
||||||
@@ -522,12 +599,25 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
if (source == 'deezer') {
|
if (source == 'deezer') {
|
||||||
_log.d('Calling Deezer search API...');
|
_log.d('Calling Deezer search API...');
|
||||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 2, filter: currentFilter);
|
results = await PlatformBridge.searchDeezerAll(
|
||||||
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums');
|
query,
|
||||||
|
trackLimit: 20,
|
||||||
|
artistLimit: 2,
|
||||||
|
filter: currentFilter,
|
||||||
|
);
|
||||||
|
_log.i(
|
||||||
|
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
_log.d('Calling Spotify search API...');
|
_log.d('Calling Spotify search API...');
|
||||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 2);
|
results = await PlatformBridge.searchSpotifyAll(
|
||||||
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
query,
|
||||||
|
trackLimit: 20,
|
||||||
|
artistLimit: 2,
|
||||||
|
);
|
||||||
|
_log.i(
|
||||||
|
'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) {
|
if (!_isRequestValid(requestId)) {
|
||||||
@@ -539,7 +629,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||||
final albumList = results['albums'] as List<dynamic>? ?? [];
|
final albumList = results['albums'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums');
|
_log.d(
|
||||||
|
'Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
|
||||||
|
);
|
||||||
|
|
||||||
final tracks = <Track>[];
|
final tracks = <Track>[];
|
||||||
|
|
||||||
@@ -610,7 +702,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully');
|
_log.i(
|
||||||
|
'Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully',
|
||||||
|
);
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -624,23 +718,37 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
_log.e('Search failed: $e', e, stackTrace);
|
_log.e('Search failed: $e', e, stackTrace);
|
||||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
|
state = TrackState(
|
||||||
|
isLoading: false,
|
||||||
|
error: e.toString(),
|
||||||
|
hasSearchText: state.hasSearchText,
|
||||||
|
selectedSearchFilter: currentFilter,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
Future<void> customSearch(
|
||||||
|
String extensionId,
|
||||||
|
String query, {
|
||||||
|
Map<String, dynamic>? options,
|
||||||
|
}) async {
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading
|
selectedSearchFilter:
|
||||||
|
state.selectedSearchFilter, // Preserve filter during loading
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_log.i('Custom search started: extension=$extensionId, query="$query"');
|
_log.i('Custom search started: extension=$extensionId, query="$query"');
|
||||||
|
|
||||||
final results = await PlatformBridge.customSearchWithExtension(extensionId, query, options: options);
|
final results = await PlatformBridge.customSearchWithExtension(
|
||||||
|
extensionId,
|
||||||
|
query,
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) {
|
if (!_isRequestValid(requestId)) {
|
||||||
_log.w('Custom search request cancelled (requestId=$requestId)');
|
_log.w('Custom search request cancelled (requestId=$requestId)');
|
||||||
@@ -659,7 +767,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i('Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)');
|
_log.i(
|
||||||
|
'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)',
|
||||||
|
);
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -667,12 +777,17 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
searchExtensionId: extensionId, // Store which extension was used
|
searchExtensionId: extensionId, // Store which extension was used
|
||||||
selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter
|
selectedSearchFilter:
|
||||||
|
state.selectedSearchFilter, // Preserve selected filter
|
||||||
);
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
_log.e('Custom search failed: $e', e, stackTrace);
|
_log.e('Custom search failed: $e', e, stackTrace);
|
||||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
state = TrackState(
|
||||||
|
isLoading: false,
|
||||||
|
error: e.toString(),
|
||||||
|
hasSearchText: state.hasSearchText,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,7 +798,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
if (track.isrc == null || track.isrc!.isEmpty) return;
|
if (track.isrc == null || track.isrc!.isEmpty) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final availability = await PlatformBridge.checkAvailability(track.id, track.isrc!);
|
final availability = await PlatformBridge.checkAvailability(
|
||||||
|
track.id,
|
||||||
|
track.isrc!,
|
||||||
|
);
|
||||||
final updatedTrack = Track(
|
final updatedTrack = Track(
|
||||||
id: track.id,
|
id: track.id,
|
||||||
name: track.name,
|
name: track.name,
|
||||||
@@ -738,6 +856,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setShowingRecentAccess(bool showing) {
|
void setShowingRecentAccess(bool showing) {
|
||||||
|
if (state.isShowingRecentAccess == showing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
state = state.copyWith(isShowingRecentAccess: showing);
|
state = state.copyWith(isShowingRecentAccess: showing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +918,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
source:
|
||||||
|
source ??
|
||||||
|
data['source']?.toString() ??
|
||||||
|
data['provider_id']?.toString(),
|
||||||
albumType: data['album_type']?.toString(),
|
albumType: data['album_type']?.toString(),
|
||||||
itemType: itemType,
|
itemType: itemType,
|
||||||
);
|
);
|
||||||
@@ -849,16 +973,25 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _preWarmCacheForTracks(List<Track> tracks) {
|
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||||
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
if (tracks.isEmpty) return;
|
||||||
if (tracksWithIsrc.isEmpty) return;
|
final cacheRequests = <Map<String, String>>[];
|
||||||
|
for (final track in tracks) {
|
||||||
final cacheRequests = tracksWithIsrc.map((t) => {
|
final isrc = track.isrc;
|
||||||
'isrc': t.isrc!,
|
if (isrc == null || isrc.isEmpty) {
|
||||||
'track_name': t.name,
|
continue;
|
||||||
'artist_name': t.artistName,
|
}
|
||||||
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
|
cacheRequests.add({
|
||||||
'service': 'tidal',
|
'isrc': isrc,
|
||||||
}).toList();
|
'track_name': track.name,
|
||||||
|
'artist_name': track.artistName,
|
||||||
|
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
|
||||||
|
'service': 'tidal',
|
||||||
|
});
|
||||||
|
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cacheRequests.isEmpty) return;
|
||||||
|
|
||||||
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
|
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:spotiflac_android/services/palette_service.dart';
|
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
@@ -14,7 +15,8 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
|||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen;
|
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||||
|
show ExtensionArtistScreen;
|
||||||
|
|
||||||
class _AlbumCache {
|
class _AlbumCache {
|
||||||
static final Map<String, _CacheEntry> _cache = {};
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
@@ -69,7 +71,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
List<Track>? _tracks;
|
List<Track>? _tracks;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
Color? _dominantColor;
|
|
||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
String? _artistId;
|
String? _artistId;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
@@ -82,15 +83,18 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
// Use extensionId if available, otherwise detect from albumId prefix
|
// Use extensionId if available, otherwise detect from albumId prefix
|
||||||
final providerId = widget.extensionId ??
|
final providerId =
|
||||||
|
widget.extensionId ??
|
||||||
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
|
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
|
||||||
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
|
ref
|
||||||
id: widget.albumId,
|
.read(recentAccessProvider.notifier)
|
||||||
name: widget.albumName,
|
.recordAlbumAccess(
|
||||||
artistName: widget.tracks?.firstOrNull?.artistName,
|
id: widget.albumId,
|
||||||
imageUrl: widget.coverUrl,
|
name: widget.albumName,
|
||||||
providerId: providerId,
|
artistName: widget.tracks?.firstOrNull?.artistName,
|
||||||
);
|
imageUrl: widget.coverUrl,
|
||||||
|
providerId: providerId,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
|
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
|
||||||
@@ -103,8 +107,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
if (_tracks == null || _tracks!.isEmpty) {
|
if (_tracks == null || _tracks!.isEmpty) {
|
||||||
_fetchTracks();
|
_fetchTracks();
|
||||||
}
|
}
|
||||||
|
|
||||||
_extractDominantColor();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -121,14 +123,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _extractDominantColor() async {
|
|
||||||
if (widget.coverUrl == null) return;
|
|
||||||
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
|
|
||||||
if (mounted && color != null) {
|
|
||||||
setState(() => _dominantColor = color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatReleaseDate(String date) {
|
String _formatReleaseDate(String date) {
|
||||||
if (date.length >= 10) {
|
if (date.length >= 10) {
|
||||||
final parts = date.substring(0, 10).split('-');
|
final parts = date.substring(0, 10).split('-');
|
||||||
@@ -144,21 +138,26 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchTracks() async {
|
Future<void> _fetchTracks() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
Map<String, dynamic> metadata;
|
Map<String, dynamic> metadata;
|
||||||
|
|
||||||
if (widget.albumId.startsWith('deezer:')) {
|
if (widget.albumId.startsWith('deezer:')) {
|
||||||
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||||
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
|
metadata = await PlatformBridge.getDeezerMetadata(
|
||||||
|
'album',
|
||||||
|
deezerAlbumId,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||||
final artistId = albumInfo?['artist_id'] as String?;
|
final artistId = albumInfo?['artist_id'] as String?;
|
||||||
@@ -210,15 +209,19 @@ Future<void> _fetchTracks() async {
|
|||||||
_buildAppBar(context, colorScheme),
|
_buildAppBar(context, colorScheme),
|
||||||
_buildInfoCard(context, colorScheme),
|
_buildInfoCard(context, colorScheme),
|
||||||
if (_isLoading)
|
if (_isLoading)
|
||||||
const SliverToBoxAdapter(child: Padding(
|
const SliverToBoxAdapter(
|
||||||
padding: EdgeInsets.all(32),
|
child: Padding(
|
||||||
child: Center(child: CircularProgressIndicator()),
|
padding: EdgeInsets.all(32),
|
||||||
)),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
if (_error != null)
|
),
|
||||||
SliverToBoxAdapter(child: Padding(
|
),
|
||||||
padding: const EdgeInsets.all(16),
|
if (_error != null)
|
||||||
child: _buildErrorWidget(_error!, colorScheme),
|
SliverToBoxAdapter(
|
||||||
)),
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
||||||
_buildTrackListHeader(context, colorScheme),
|
_buildTrackListHeader(context, colorScheme),
|
||||||
_buildTrackList(context, colorScheme, tracks),
|
_buildTrackList(context, colorScheme, tracks),
|
||||||
@@ -230,12 +233,17 @@ Future<void> _fetchTracks() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final mediaSize = MediaQuery.of(context).size;
|
||||||
final coverSize = screenWidth * 0.5;
|
final screenWidth = mediaSize.width;
|
||||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
final shortestSide = mediaSize.shortestSide;
|
||||||
|
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
|
||||||
|
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
|
||||||
|
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||||
|
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||||
|
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: 320,
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
stretch: true,
|
stretch: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
@@ -256,26 +264,60 @@ Future<void> _fetchTracks() async {
|
|||||||
),
|
),
|
||||||
flexibleSpace: LayoutBuilder(
|
flexibleSpace: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
final collapseRatio =
|
||||||
|
(constraints.maxHeight - kToolbarHeight) /
|
||||||
|
(expandedHeight - kToolbarHeight);
|
||||||
final showContent = collapseRatio > 0.3;
|
final showContent = collapseRatio > 0.3;
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(
|
||||||
|
context,
|
||||||
|
).clamp(1.0, 3.0).toDouble();
|
||||||
|
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
|
||||||
|
.round()
|
||||||
|
.clamp(720, 1440)
|
||||||
|
.toInt();
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
collapseMode: CollapseMode.none,
|
collapseMode: CollapseMode.none,
|
||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
AnimatedContainer(
|
// Blurred cover background
|
||||||
duration: const Duration(milliseconds: 500),
|
if (widget.coverUrl != null)
|
||||||
decoration: BoxDecoration(
|
CachedNetworkImage(
|
||||||
gradient: LinearGradient(
|
imageUrl: widget.coverUrl!,
|
||||||
begin: Alignment.topCenter,
|
fit: BoxFit.cover,
|
||||||
end: Alignment.bottomCenter,
|
memCacheWidth: backgroundMemCacheWidth,
|
||||||
colors: [
|
cacheManager: CoverCacheManager.instance,
|
||||||
bgColor,
|
placeholder: (_, _) =>
|
||||||
bgColor.withValues(alpha: 0.8),
|
Container(color: colorScheme.surface),
|
||||||
colorScheme.surface,
|
errorWidget: (_, _, _) =>
|
||||||
],
|
Container(color: colorScheme.surface),
|
||||||
stops: const [0.0, 0.6, 1.0],
|
)
|
||||||
|
else
|
||||||
|
Container(color: colorScheme.surface),
|
||||||
|
ClipRect(
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surface.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: bottomGradientHeight,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surface.withValues(alpha: 0.0),
|
||||||
|
colorScheme.surface,
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -284,7 +326,7 @@ Future<void> _fetchTracks() async {
|
|||||||
opacity: showContent ? 1.0 : 0.0,
|
opacity: showContent ? 1.0 : 0.0,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 60),
|
padding: EdgeInsets.only(top: coverTopPadding),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: coverSize,
|
width: coverSize,
|
||||||
height: coverSize,
|
height: coverSize,
|
||||||
@@ -301,7 +343,7 @@ Future<void> _fetchTracks() async {
|
|||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: widget.coverUrl != null
|
child: widget.coverUrl != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: widget.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: (coverSize * 2).toInt(),
|
memCacheWidth: (coverSize * 2).toInt(),
|
||||||
@@ -309,7 +351,11 @@ Future<void> _fetchTracks() async {
|
|||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
child: Icon(
|
||||||
|
Icons.album,
|
||||||
|
size: fallbackIconSize,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -318,7 +364,10 @@ Future<void> _fetchTracks() async {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
stretchModes: const [
|
||||||
|
StretchMode.zoomBackground,
|
||||||
|
StretchMode.blurBackground,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -336,7 +385,7 @@ Future<void> _fetchTracks() async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||||
final tracks = _tracks ?? [];
|
final tracks = _tracks ?? [];
|
||||||
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
||||||
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||||
@@ -347,7 +396,9 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
|||||||
child: Card(
|
child: Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.surfaceContainerLow,
|
color: colorScheme.surfaceContainerLow,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -355,7 +406,10 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
widget.albumName,
|
widget.albumName,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (artistName != null && artistName.isNotEmpty) ...[
|
if (artistName != null && artistName.isNotEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -376,27 +430,61 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
|||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
size: 14,
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
Text(
|
||||||
|
context.l10n.tracksCount(tracks.length),
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (releaseDate != null && releaseDate.isNotEmpty)
|
if (releaseDate != null && releaseDate.isNotEmpty)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.tertiaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer),
|
Icon(
|
||||||
|
Icons.calendar_today,
|
||||||
|
size: 14,
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(_formatReleaseDate(releaseDate), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
Text(
|
||||||
|
_formatReleaseDate(releaseDate),
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -410,7 +498,9 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
|||||||
label: Text(context.l10n.downloadAllCount(tracks.length)),
|
label: Text(context.l10n.downloadAllCount(tracks.length)),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(48),
|
minimumSize: const Size.fromHeight(48),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -430,28 +520,35 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
Text(
|
||||||
|
context.l10n.tracksHeader,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<Track> tracks) {
|
Widget _buildTrackList(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
List<Track> tracks,
|
||||||
|
) {
|
||||||
return SliverList(
|
return SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
(context, index) {
|
final track = tracks[index];
|
||||||
final track = tracks[index];
|
return KeyedSubtree(
|
||||||
return KeyedSubtree(
|
key: ValueKey(track.id),
|
||||||
key: ValueKey(track.id),
|
child: _AlbumTrackItem(
|
||||||
child: _AlbumTrackItem(
|
track: track,
|
||||||
track: track,
|
onDownload: () => _downloadTrack(context, track),
|
||||||
onDownload: () => _downloadTrack(context, track),
|
),
|
||||||
),
|
);
|
||||||
);
|
}, childCount: tracks.length),
|
||||||
},
|
|
||||||
childCount: tracks.length,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,13 +561,23 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
|||||||
artistName: track.artistName,
|
artistName: track.artistName,
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
ref
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
|
.read(downloadQueueProvider.notifier)
|
||||||
|
.addToQueue(track, service, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
ref
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
|
.read(downloadQueueProvider.notifier)
|
||||||
|
.addToQueue(track, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,21 +591,38 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
|||||||
trackName: '${tracks.length} tracks',
|
trackName: '${tracks.length} tracks',
|
||||||
artistName: widget.albumName,
|
artistName: widget.albumName,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
ref
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
.read(downloadQueueProvider.notifier)
|
||||||
|
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
ref
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
.read(downloadQueueProvider.notifier)
|
||||||
|
.addMultipleToQueue(tracks, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToArtist(BuildContext context, String artistName) {
|
void _navigateToArtist(BuildContext context, String artistName) {
|
||||||
final artistId = _artistId ??
|
final artistId =
|
||||||
|
_artistId ??
|
||||||
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
|
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
|
||||||
|
|
||||||
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) {
|
if (artistId == 'unknown' ||
|
||||||
|
artistId == 'deezer:unknown' ||
|
||||||
|
artistId.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Artist information not available')),
|
SnackBar(content: Text('Artist information not available')),
|
||||||
);
|
);
|
||||||
@@ -533,9 +657,10 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
final isRateLimit = error.contains('429') ||
|
final isRateLimit =
|
||||||
error.toLowerCase().contains('rate limit') ||
|
error.contains('429') ||
|
||||||
error.toLowerCase().contains('too many requests');
|
error.toLowerCase().contains('rate limit') ||
|
||||||
|
error.toLowerCase().contains('too many requests');
|
||||||
|
|
||||||
if (isRateLimit) {
|
if (isRateLimit) {
|
||||||
return Card(
|
return Card(
|
||||||
@@ -586,7 +711,9 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.error_outline, color: colorScheme.error),
|
Icon(Icons.error_outline, color: colorScheme.error),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
|
Expanded(
|
||||||
|
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -605,22 +732,32 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
final queueItem = ref.watch(
|
final queueItem = ref.watch(
|
||||||
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
|
downloadQueueLookupProvider.select(
|
||||||
|
(lookup) => lookup.byTrackId[track.id],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
final isInHistory = ref.watch(
|
||||||
return state.isDownloaded(track.id);
|
downloadHistoryProvider.select((state) {
|
||||||
}));
|
return state.isDownloaded(track.id);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
final settings = ref.watch(settingsProvider);
|
final showLocalLibraryIndicator = ref.watch(
|
||||||
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
|
settingsProvider.select(
|
||||||
|
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
||||||
|
),
|
||||||
|
);
|
||||||
final isInLocalLibrary = showLocalLibraryIndicator
|
final isInLocalLibrary = showLocalLibraryIndicator
|
||||||
? ref.watch(localLibraryProvider.select((state) =>
|
? ref.watch(
|
||||||
state.existsInLibrary(
|
localLibraryProvider.select(
|
||||||
isrc: track.isrc,
|
(state) => state.existsInLibrary(
|
||||||
trackName: track.name,
|
isrc: track.isrc,
|
||||||
artistName: track.artistName,
|
trackName: track.name,
|
||||||
)))
|
artistName: track.artistName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
final isQueued = queueItem != null;
|
final isQueued = queueItem != null;
|
||||||
@@ -629,7 +766,8 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
final progress = queueItem?.progress ?? 0.0;
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
final showAsDownloaded =
|
||||||
|
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
@@ -637,8 +775,10 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
leading: SizedBox(
|
leading: SizedBox(
|
||||||
width: 32,
|
width: 32,
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -651,14 +791,31 @@ child: ListTile(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
title: Text(
|
||||||
|
track.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
subtitle: Row(
|
subtitle: Row(
|
||||||
children: [
|
children: [
|
||||||
Flexible(child: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant))),
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
track.artistName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (isInLocalLibrary) ...[
|
if (isInLocalLibrary) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.tertiaryContainer,
|
color: colorScheme.tertiaryContainer,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
@@ -666,43 +823,91 @@ child: ListTile(
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer),
|
Icon(
|
||||||
|
Icons.folder_outlined,
|
||||||
|
size: 10,
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
const SizedBox(width: 3),
|
const SizedBox(width: 3),
|
||||||
Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)),
|
Text(
|
||||||
|
context.l10n.libraryInLibrary,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress),
|
trailing: _buildDownloadButton(
|
||||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
|
context,
|
||||||
|
ref,
|
||||||
|
colorScheme,
|
||||||
|
isQueued: isQueued,
|
||||||
|
isDownloading: isDownloading,
|
||||||
|
isFinalizing: isFinalizing,
|
||||||
|
showAsDownloaded: showAsDownloaded,
|
||||||
|
isInHistory: isInHistory,
|
||||||
|
isInLocalLibrary: isInLocalLibrary,
|
||||||
|
progress: progress,
|
||||||
|
),
|
||||||
|
onTap: () => _handleTap(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
isQueued: isQueued,
|
||||||
|
isInHistory: isInHistory,
|
||||||
|
isInLocalLibrary: isInLocalLibrary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async {
|
void _handleTap(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref, {
|
||||||
|
required bool isQueued,
|
||||||
|
required bool isInHistory,
|
||||||
|
required bool isInLocalLibrary,
|
||||||
|
}) async {
|
||||||
if (isQueued) return;
|
if (isQueued) return;
|
||||||
|
|
||||||
if (isInLocalLibrary) {
|
if (isInLocalLibrary) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))));
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInHistory) {
|
if (isInHistory) {
|
||||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
final historyItem = ref
|
||||||
|
.read(downloadHistoryProvider.notifier)
|
||||||
|
.getBySpotifyId(track.id);
|
||||||
if (historyItem != null) {
|
if (historyItem != null) {
|
||||||
final exists = await fileExists(historyItem.filePath);
|
final exists = await fileExists(historyItem.filePath);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.snackbarAlreadyDownloaded(track.name),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
ref
|
||||||
|
.read(downloadHistoryProvider.notifier)
|
||||||
|
.removeBySpotifyId(track.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -710,7 +915,10 @@ child: ListTile(
|
|||||||
onDownload();
|
onDownload();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
|
Widget _buildDownloadButton(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ColorScheme colorScheme, {
|
||||||
required bool isQueued,
|
required bool isQueued,
|
||||||
required bool isDownloading,
|
required bool isDownloading,
|
||||||
required bool isFinalizing,
|
required bool isFinalizing,
|
||||||
@@ -724,8 +932,26 @@ child: ListTile(
|
|||||||
|
|
||||||
if (showAsDownloaded) {
|
if (showAsDownloaded) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
|
onTap: () => _handleTap(
|
||||||
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
context,
|
||||||
|
ref,
|
||||||
|
isQueued: isQueued,
|
||||||
|
isInHistory: isInHistory,
|
||||||
|
isInLocalLibrary: isInLocalLibrary,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
size: iconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else if (isFinalizing) {
|
} else if (isFinalizing) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@@ -734,7 +960,11 @@ child: ListTile(
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
|
CircularProgressIndicator(
|
||||||
|
strokeWidth: 3,
|
||||||
|
color: colorScheme.tertiary,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -746,17 +976,54 @@ child: ListTile(
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
|
CircularProgressIndicator(
|
||||||
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
value: progress > 0 ? progress : null,
|
||||||
|
strokeWidth: 3,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
if (progress > 0)
|
||||||
|
Text(
|
||||||
|
'${(progress * 100).toInt()}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (isQueued) {
|
} else if (isQueued) {
|
||||||
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.hourglass_empty,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: iconSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onDownload,
|
onTap: onDownload,
|
||||||
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.secondaryContainer,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.download,
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
size: iconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
import 'package:spotiflac_android/services/palette_service.dart';
|
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||||
|
|
||||||
/// Screen to display downloaded tracks from a specific album
|
/// Screen to display downloaded tracks from a specific album
|
||||||
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||||
@@ -23,21 +25,34 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState();
|
ConsumerState<DownloadedAlbumScreen> createState() =>
|
||||||
|
_DownloadedAlbumScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||||
bool _isSelectionMode = false;
|
bool _isSelectionMode = false;
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
Color? _dominantColor;
|
|
||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
bool _embeddedCoverRefreshScheduled = false;
|
||||||
|
List<DownloadHistoryItem>? _albumTracksSourceCache;
|
||||||
|
List<DownloadHistoryItem>? _albumTracksCache;
|
||||||
|
List<DownloadHistoryItem>? _discGroupingSourceCache;
|
||||||
|
Map<int, List<DownloadHistoryItem>>? _discGroupingCache;
|
||||||
|
List<int>? _sortedDiscNumbersCache;
|
||||||
|
List<DownloadHistoryItem>? _commonQualitySourceCache;
|
||||||
|
String? _commonQualityCache;
|
||||||
|
List<DownloadHistoryItem>? _embeddedCoverSourceCache;
|
||||||
|
String? _embeddedCoverPathCache;
|
||||||
|
bool _embeddedCoverPathResolved = false;
|
||||||
|
|
||||||
|
String get _albumLookupKey =>
|
||||||
|
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
_extractDominantColor();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -47,6 +62,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant DownloadedAlbumScreen oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.albumName != widget.albumName ||
|
||||||
|
oldWidget.artistName != widget.artistName) {
|
||||||
|
_albumTracksSourceCache = null;
|
||||||
|
_albumTracksCache = null;
|
||||||
|
_invalidateDerivedTrackCaches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
final shouldShow = _scrollController.offset > 280;
|
final shouldShow = _scrollController.offset > 280;
|
||||||
if (shouldShow != _showTitleInAppBar) {
|
if (shouldShow != _showTitleInAppBar) {
|
||||||
@@ -54,64 +80,78 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _extractDominantColor() async {
|
|
||||||
if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return;
|
|
||||||
|
|
||||||
// Check cache first (instant)
|
|
||||||
final cached = PaletteService.instance.getCached(widget.coverUrl);
|
|
||||||
if (cached != null) {
|
|
||||||
if (mounted && cached != _dominantColor) {
|
|
||||||
setState(() {
|
|
||||||
_dominantColor = cached;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract in isolate (non-blocking)
|
|
||||||
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
|
|
||||||
if (mounted && color != null && color != _dominantColor) {
|
|
||||||
setState(() {
|
|
||||||
_dominantColor = color;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get tracks for this album from history provider (reactive)
|
/// Get tracks for this album from history provider (reactive)
|
||||||
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
List<DownloadHistoryItem> _getAlbumTracks(
|
||||||
return allItems.where((item) {
|
List<DownloadHistoryItem> allItems,
|
||||||
// Use albumArtist if available and not empty, otherwise artistName
|
) {
|
||||||
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
final cached = _albumTracksCache;
|
||||||
? item.albumArtist!
|
if (cached != null && identical(allItems, _albumTracksSourceCache)) {
|
||||||
: item.artistName;
|
return cached;
|
||||||
// Use lowercase for case-insensitive matching
|
}
|
||||||
final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
|
||||||
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
final tracks =
|
||||||
return itemKey == albumKey;
|
allItems.where((item) {
|
||||||
}).toList()
|
// Use albumArtist if available and not empty, otherwise artistName
|
||||||
..sort((a, b) {
|
final itemArtist =
|
||||||
// Sort by disc number first, then by track number
|
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||||
final aDisc = a.discNumber ?? 1;
|
? item.albumArtist!
|
||||||
final bDisc = b.discNumber ?? 1;
|
: item.artistName;
|
||||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
// Use lowercase for case-insensitive matching
|
||||||
final aNum = a.trackNumber ?? 999;
|
final itemKey =
|
||||||
final bNum = b.trackNumber ?? 999;
|
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
return itemKey == _albumLookupKey;
|
||||||
return a.trackName.compareTo(b.trackName);
|
}).toList()..sort((a, b) {
|
||||||
});
|
// Sort by disc number first, then by track number
|
||||||
|
final aDisc = a.discNumber ?? 1;
|
||||||
|
final bDisc = b.discNumber ?? 1;
|
||||||
|
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||||
|
final aNum = a.trackNumber ?? 999;
|
||||||
|
final bNum = b.trackNumber ?? 999;
|
||||||
|
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||||
|
return a.trackName.compareTo(b.trackName);
|
||||||
|
});
|
||||||
|
|
||||||
|
_albumTracksSourceCache = allItems;
|
||||||
|
_albumTracksCache = tracks;
|
||||||
|
_invalidateDerivedTrackCaches();
|
||||||
|
return tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
|
void _invalidateDerivedTrackCaches() {
|
||||||
|
_discGroupingSourceCache = null;
|
||||||
|
_discGroupingCache = null;
|
||||||
|
_sortedDiscNumbersCache = null;
|
||||||
|
_commonQualitySourceCache = null;
|
||||||
|
_commonQualityCache = null;
|
||||||
|
_embeddedCoverSourceCache = null;
|
||||||
|
_embeddedCoverPathCache = null;
|
||||||
|
_embeddedCoverPathResolved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<int, List<DownloadHistoryItem>> _getDiscGroups(
|
||||||
List<DownloadHistoryItem> tracks,
|
List<DownloadHistoryItem> tracks,
|
||||||
) {
|
) {
|
||||||
|
final cached = _discGroupingCache;
|
||||||
|
if (cached != null && identical(tracks, _discGroupingSourceCache)) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
final discMap = <int, List<DownloadHistoryItem>>{};
|
final discMap = <int, List<DownloadHistoryItem>>{};
|
||||||
for (final track in tracks) {
|
for (final track in tracks) {
|
||||||
final discNumber = track.discNumber ?? 1;
|
final discNumber = track.discNumber ?? 1;
|
||||||
discMap.putIfAbsent(discNumber, () => []).add(track);
|
discMap.putIfAbsent(discNumber, () => []).add(track);
|
||||||
}
|
}
|
||||||
|
_discGroupingSourceCache = tracks;
|
||||||
|
_discGroupingCache = discMap;
|
||||||
|
_sortedDiscNumbersCache = discMap.keys.toList()..sort();
|
||||||
return discMap;
|
return discMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<int> _getSortedDiscNumbers(List<DownloadHistoryItem> tracks) {
|
||||||
|
_getDiscGroups(tracks);
|
||||||
|
return _sortedDiscNumbersCache ?? const [];
|
||||||
|
}
|
||||||
|
|
||||||
void _enterSelectionMode(String itemId) {
|
void _enterSelectionMode(String itemId) {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -172,10 +212,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||||
final idsToDelete = _selectedIds.toList();
|
final idsToDelete = _selectedIds.toList();
|
||||||
|
final tracksById = {for (final track in currentTracks) track.id: track};
|
||||||
|
|
||||||
int deletedCount = 0;
|
int deletedCount = 0;
|
||||||
for (final id in idsToDelete) {
|
for (final id in idsToDelete) {
|
||||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
final item = tracksById[id];
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
try {
|
try {
|
||||||
await deleteFile(item.filePath);
|
await deleteFile(item.filePath);
|
||||||
@@ -189,7 +230,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
|
SnackBar(
|
||||||
|
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,20 +244,51 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
|
SnackBar(
|
||||||
|
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
void _onEmbeddedCoverChanged() {
|
||||||
|
if (!mounted || _embeddedCoverRefreshScheduled) return;
|
||||||
|
_embeddedCoverRefreshScheduled = true;
|
||||||
|
_embeddedCoverPathResolved = false;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_embeddedCoverRefreshScheduled = false;
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
_precacheCover(item.coverUrl);
|
_precacheCover(item.coverUrl);
|
||||||
Navigator.push(context, PageRouteBuilder(
|
final beforeModTime =
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
item.filePath,
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
|
);
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
|
if (!mounted) return;
|
||||||
));
|
|
||||||
|
final result = await navigator.push(
|
||||||
|
PageRouteBuilder(
|
||||||
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||||
|
TrackMetadataScreen(item: item),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||||
|
FadeTransition(opacity: animation, child: child),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||||
|
item.filePath,
|
||||||
|
beforeModTime: beforeModTime,
|
||||||
|
force: result == true,
|
||||||
|
onChanged: _onEmbeddedCoverChanged,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _precacheCover(String? url) {
|
void _precacheCover(String? url) {
|
||||||
@@ -222,8 +296,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(
|
||||||
|
context,
|
||||||
|
).clamp(1.0, 3.0).toDouble();
|
||||||
|
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
|
||||||
precacheImage(
|
precacheImage(
|
||||||
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
|
ResizeImage(
|
||||||
|
CachedNetworkImageProvider(
|
||||||
|
url,
|
||||||
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
),
|
||||||
|
width: targetSize,
|
||||||
|
height: targetSize,
|
||||||
|
),
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -233,18 +318,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
|
||||||
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
final allHistoryItems = ref.watch(
|
||||||
|
downloadHistoryProvider.select((s) => s.items),
|
||||||
|
);
|
||||||
final tracks = _getAlbumTracks(allHistoryItems);
|
final tracks = _getAlbumTracks(allHistoryItems);
|
||||||
|
|
||||||
// Show empty state if no tracks found
|
// Show empty state if no tracks found
|
||||||
if (tracks.isEmpty) {
|
if (tracks.isEmpty) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: Text(widget.albumName)),
|
||||||
title: Text(widget.albumName),
|
body: Center(child: Text('No tracks found for this album')),
|
||||||
),
|
|
||||||
body: Center(
|
|
||||||
child: Text('No tracks found for this album'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,11 +352,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
CustomScrollView(
|
CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
_buildAppBar(context, colorScheme),
|
_buildAppBar(context, colorScheme, tracks),
|
||||||
_buildInfoCard(context, colorScheme, tracks),
|
_buildInfoCard(context, colorScheme, tracks),
|
||||||
_buildTrackListHeader(context, colorScheme, tracks),
|
_buildTrackListHeader(context, colorScheme, tracks),
|
||||||
_buildTrackList(context, colorScheme, tracks),
|
_buildTrackList(context, colorScheme, tracks),
|
||||||
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
SliverToBoxAdapter(
|
||||||
|
child: SizedBox(height: _isSelectionMode ? 120 : 32),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -283,7 +368,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
||||||
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
|
child: _buildSelectionBottomBar(
|
||||||
|
context,
|
||||||
|
colorScheme,
|
||||||
|
tracks,
|
||||||
|
bottomPadding,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -291,16 +381,48 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
String? _resolveAlbumEmbeddedCoverPath(List<DownloadHistoryItem> tracks) {
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
if (_embeddedCoverPathResolved &&
|
||||||
final coverSize = screenWidth * 0.5; // 50% of screen width
|
identical(tracks, _embeddedCoverSourceCache)) {
|
||||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
return _embeddedCoverPathCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
_embeddedCoverSourceCache = tracks;
|
||||||
|
_embeddedCoverPathResolved = true;
|
||||||
|
|
||||||
|
if (tracks.isEmpty) {
|
||||||
|
_embeddedCoverPathCache = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_embeddedCoverPathCache = DownloadedEmbeddedCoverResolver.resolve(
|
||||||
|
tracks.first.filePath,
|
||||||
|
onChanged: _onEmbeddedCoverChanged,
|
||||||
|
);
|
||||||
|
return _embeddedCoverPathCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
List<DownloadHistoryItem> tracks,
|
||||||
|
) {
|
||||||
|
final mediaSize = MediaQuery.of(context).size;
|
||||||
|
final screenWidth = mediaSize.width;
|
||||||
|
final shortestSide = mediaSize.shortestSide;
|
||||||
|
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
|
||||||
|
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
|
||||||
|
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||||
|
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||||
|
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||||
|
final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks);
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: 320,
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
stretch: true,
|
stretch: true,
|
||||||
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
|
backgroundColor:
|
||||||
|
colorScheme.surface, // Use theme color for collapsed state
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
title: AnimatedOpacity(
|
title: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
@@ -318,27 +440,68 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
flexibleSpace: LayoutBuilder(
|
flexibleSpace: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
final collapseRatio =
|
||||||
|
(constraints.maxHeight - kToolbarHeight) /
|
||||||
|
(expandedHeight - kToolbarHeight);
|
||||||
final showContent = collapseRatio > 0.3;
|
final showContent = collapseRatio > 0.3;
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(
|
||||||
|
context,
|
||||||
|
).clamp(1.0, 3.0).toDouble();
|
||||||
|
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
|
||||||
|
.round()
|
||||||
|
.clamp(720, 1440)
|
||||||
|
.toInt();
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
collapseMode: CollapseMode.none,
|
collapseMode: CollapseMode.none,
|
||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Background with dominant color
|
// Blurred cover background
|
||||||
AnimatedContainer(
|
if (embeddedCoverPath != null)
|
||||||
duration: const Duration(milliseconds: 500),
|
Image.file(
|
||||||
decoration: BoxDecoration(
|
File(embeddedCoverPath),
|
||||||
gradient: LinearGradient(
|
fit: BoxFit.cover,
|
||||||
begin: Alignment.topCenter,
|
cacheWidth: backgroundMemCacheWidth,
|
||||||
end: Alignment.bottomCenter,
|
errorBuilder: (_, _, _) =>
|
||||||
colors: [
|
Container(color: colorScheme.surface),
|
||||||
bgColor,
|
)
|
||||||
bgColor.withValues(alpha: 0.8),
|
else if (widget.coverUrl != null)
|
||||||
colorScheme.surface,
|
CachedNetworkImage(
|
||||||
],
|
imageUrl: widget.coverUrl!,
|
||||||
stops: const [0.0, 0.6, 1.0],
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: backgroundMemCacheWidth,
|
||||||
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
placeholder: (_, _) =>
|
||||||
|
Container(color: colorScheme.surface),
|
||||||
|
errorWidget: (_, _, _) =>
|
||||||
|
Container(color: colorScheme.surface),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(color: colorScheme.surface),
|
||||||
|
ClipRect(
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surface.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: bottomGradientHeight,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surface.withValues(alpha: 0.0),
|
||||||
|
colorScheme.surface,
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -348,7 +511,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
opacity: showContent ? 1.0 : 0.0,
|
opacity: showContent ? 1.0 : 0.0,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 60),
|
padding: EdgeInsets.only(top: coverTopPadding),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: coverSize,
|
width: coverSize,
|
||||||
height: coverSize,
|
height: coverSize,
|
||||||
@@ -364,8 +527,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: widget.coverUrl != null
|
child: embeddedCoverPath != null
|
||||||
? CachedNetworkImage(
|
? Image.file(
|
||||||
|
File(embeddedCoverPath),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
cacheWidth: (coverSize * 2).toInt(),
|
||||||
|
cacheHeight: (coverSize * 2).toInt(),
|
||||||
|
errorBuilder: (_, _, _) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.album,
|
||||||
|
size: fallbackIconSize,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: widget.coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: widget.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: (coverSize * 2).toInt(),
|
memCacheWidth: (coverSize * 2).toInt(),
|
||||||
@@ -373,7 +551,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
child: Icon(
|
||||||
|
Icons.album,
|
||||||
|
size: fallbackIconSize,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -382,14 +564,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
stretchModes: const [
|
||||||
|
StretchMode.zoomBackground,
|
||||||
|
StretchMode.blurBackground,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: Container(
|
icon: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
@@ -397,14 +585,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
Widget _buildInfoCard(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
List<DownloadHistoryItem> tracks,
|
||||||
|
) {
|
||||||
|
final commonQuality = _getCommonQuality(tracks);
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Card(
|
child: Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.surfaceContainerLow,
|
color: colorScheme.surfaceContainerLow,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -412,42 +608,69 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
widget.albumName,
|
widget.albumName,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
widget.artistName,
|
widget.artistName,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
|
Icon(
|
||||||
|
Icons.download_done,
|
||||||
|
size: 14,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(context.l10n.downloadedAlbumDownloadedCount(tracks.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
Text(
|
||||||
|
context.l10n.downloadedAlbumDownloadedCount(
|
||||||
|
tracks.length,
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (_getCommonQuality(tracks) != null)
|
if (commonQuality != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
color: commonQuality.startsWith('24')
|
||||||
? colorScheme.tertiaryContainer
|
? colorScheme.tertiaryContainer
|
||||||
: colorScheme.surfaceContainerHighest,
|
: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_getCommonQuality(tracks)!,
|
commonQuality,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
color: commonQuality.startsWith('24')
|
||||||
? colorScheme.onTertiaryContainer
|
? colorScheme.onTertiaryContainer
|
||||||
: colorScheme.onSurfaceVariant,
|
: colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -466,16 +689,38 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
|
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
|
||||||
if (tracks.isEmpty) return null;
|
if (identical(tracks, _commonQualitySourceCache)) {
|
||||||
final firstQuality = tracks.first.quality;
|
return _commonQualityCache;
|
||||||
if (firstQuality == null) return null;
|
|
||||||
for (final track in tracks) {
|
|
||||||
if (track.quality != firstQuality) return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tracks.isEmpty) {
|
||||||
|
_commonQualitySourceCache = tracks;
|
||||||
|
_commonQualityCache = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final firstQuality = tracks.first.quality;
|
||||||
|
if (firstQuality == null) {
|
||||||
|
_commonQualitySourceCache = tracks;
|
||||||
|
_commonQualityCache = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final track in tracks) {
|
||||||
|
if (track.quality != firstQuality) {
|
||||||
|
_commonQualitySourceCache = tracks;
|
||||||
|
_commonQualityCache = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_commonQualitySourceCache = tracks;
|
||||||
|
_commonQualityCache = firstQuality;
|
||||||
return firstQuality;
|
return firstQuality;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
Widget _buildTrackListHeader(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
List<DownloadHistoryItem> tracks,
|
||||||
|
) {
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
@@ -483,14 +728,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
Text(
|
||||||
|
context.l10n.downloadedAlbumTracksHeader,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (!_isSelectionMode)
|
if (!_isSelectionMode)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
onPressed: tracks.isNotEmpty
|
||||||
|
? () => _enterSelectionMode(tracks.first.id)
|
||||||
|
: null,
|
||||||
icon: const Icon(Icons.checklist, size: 18),
|
icon: const Icon(Icons.checklist, size: 18),
|
||||||
label: Text(context.l10n.actionSelect),
|
label: Text(context.l10n.actionSelect),
|
||||||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
style: TextButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -498,25 +753,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
Widget _buildTrackList(
|
||||||
final discMap = _groupTracksByDisc(tracks);
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
List<DownloadHistoryItem> tracks,
|
||||||
|
) {
|
||||||
|
final discMap = _getDiscGroups(tracks);
|
||||||
|
|
||||||
if (discMap.length <= 1) {
|
if (discMap.length <= 1) {
|
||||||
return SliverList(
|
return SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
(context, index) {
|
final track = tracks[index];
|
||||||
final track = tracks[index];
|
return KeyedSubtree(
|
||||||
return KeyedSubtree(
|
key: ValueKey(track.id),
|
||||||
key: ValueKey(track.id),
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
child: _buildTrackItem(context, colorScheme, track),
|
);
|
||||||
);
|
}, childCount: tracks.length),
|
||||||
},
|
|
||||||
childCount: tracks.length,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final discNumbers = discMap.keys.toList()..sort();
|
final discNumbers = _getSortedDiscNumbers(tracks);
|
||||||
final List<Widget> children = [];
|
final List<Widget> children = [];
|
||||||
|
|
||||||
for (final discNumber in discNumbers) {
|
for (final discNumber in discNumbers) {
|
||||||
@@ -537,12 +793,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return SliverList(
|
return SliverList(delegate: SliverChildListDelegate(children));
|
||||||
delegate: SliverChildListDelegate(children),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) {
|
Widget _buildDiscSeparator(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
int discNumber,
|
||||||
|
) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -556,7 +814,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
|
Icon(
|
||||||
|
Icons.album,
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.downloadedAlbumDiscHeader(discNumber),
|
context.l10n.downloadedAlbumDiscHeader(discNumber),
|
||||||
@@ -580,21 +842,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) {
|
Widget _buildTrackItem(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
DownloadHistoryItem track,
|
||||||
|
) {
|
||||||
final isSelected = _selectedIds.contains(track.id);
|
final isSelected = _selectedIds.contains(track.id);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Card(
|
child: Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
|
color: isSelected
|
||||||
|
? colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||||
|
: Colors.transparent,
|
||||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
onTap: _isSelectionMode
|
onTap: _isSelectionMode
|
||||||
? () => _toggleSelection(track.id)
|
? () => _toggleSelection(track.id)
|
||||||
: () => _navigateToMetadataScreen(track),
|
: () => _navigateToMetadataScreen(track),
|
||||||
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
|
onLongPress: _isSelectionMode
|
||||||
|
? null
|
||||||
|
: () => _enterSelectionMode(track.id),
|
||||||
leading: Row(
|
leading: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -603,12 +875,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? colorScheme.primary : Colors.transparent,
|
color: isSelected
|
||||||
|
? colorScheme.primary
|
||||||
|
: Colors.transparent,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.outline,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: isSelected
|
child: isSelected
|
||||||
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
|
? Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
size: 16,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -630,7 +913,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
track.trackName,
|
track.trackName,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
track.artistName,
|
track.artistName,
|
||||||
@@ -638,19 +923,28 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
trailing: _isSelectionMode ? null : IconButton(
|
trailing: _isSelectionMode
|
||||||
onPressed: () => _openFile(track.filePath),
|
? null
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
: IconButton(
|
||||||
style: IconButton.styleFrom(
|
onPressed: () => _openFile(track.filePath),
|
||||||
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||||
),
|
style: IconButton.styleFrom(
|
||||||
),
|
backgroundColor: colorScheme.primaryContainer.withValues(
|
||||||
|
alpha: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks, double bottomPadding) {
|
Widget _buildSelectionBottomBar(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
List<DownloadHistoryItem> tracks,
|
||||||
|
double bottomPadding,
|
||||||
|
) {
|
||||||
final selectedCount = _selectedIds.length;
|
final selectedCount = _selectedIds.length;
|
||||||
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||||
|
|
||||||
@@ -697,12 +991,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
context.l10n.downloadedAlbumSelectedCount(
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
selectedCount,
|
||||||
|
),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
|
allSelected
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
? context.l10n.downloadedAlbumAllSelected
|
||||||
|
: context.l10n.downloadedAlbumTapToSelect,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -715,9 +1015,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
_selectAll(tracks);
|
_selectAll(tracks);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
|
icon: Icon(
|
||||||
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
|
allSelected ? Icons.deselect : Icons.select_all,
|
||||||
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
|
size: 20,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
allSelected
|
||||||
|
? context.l10n.actionDeselect
|
||||||
|
: context.l10n.actionSelectAll,
|
||||||
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.primary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -725,7 +1034,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
|
onPressed: selectedCount > 0
|
||||||
|
? () => _deleteSelected(tracks)
|
||||||
|
: null,
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
label: Text(
|
label: Text(
|
||||||
selectedCount > 0
|
selectedCount > 0
|
||||||
@@ -733,10 +1044,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
: context.l10n.downloadedAlbumSelectToDelete,
|
: context.l10n.downloadedAlbumSelectToDelete,
|
||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
|
backgroundColor: selectedCount > 0
|
||||||
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
|
? colorScheme.error
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
foregroundColor: selectedCount > 0
|
||||||
|
? colorScheme.onError
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,413 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
|
||||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
||||||
|
|
||||||
class HomeScreen extends ConsumerStatefulWidget {
|
|
||||||
const HomeScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|
||||||
final _urlController = TextEditingController();
|
|
||||||
int _currentIndex = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_urlController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pasteFromClipboard() async {
|
|
||||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
|
||||||
if (data?.text != null) {
|
|
||||||
_urlController.text = data!.text!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _fetchMetadata() async {
|
|
||||||
final url = _urlController.text.trim();
|
|
||||||
if (url.isEmpty) return;
|
|
||||||
|
|
||||||
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
|
||||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
|
||||||
} else {
|
|
||||||
final settings = ref.read(settingsProvider);
|
|
||||||
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _downloadTrack(Track track) {
|
|
||||||
final settings = ref.read(settingsProvider);
|
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(
|
|
||||||
track,
|
|
||||||
settings.defaultService,
|
|
||||||
);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Added "${track.name}" to queue')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _downloadAll() {
|
|
||||||
final trackState = ref.read(trackProvider);
|
|
||||||
if (trackState.tracks.isEmpty) return;
|
|
||||||
|
|
||||||
final settings = ref.read(settingsProvider);
|
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
|
|
||||||
trackState.tracks,
|
|
||||||
settings.defaultService,
|
|
||||||
);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onNavTap(int index) {
|
|
||||||
setState(() => _currentIndex = index);
|
|
||||||
switch (index) {
|
|
||||||
case 0:
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
context.push('/queue');
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
context.push('/history');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final trackState = ref.watch(trackProvider);
|
|
||||||
final queuedCount =
|
|
||||||
ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final tracks = trackState.tracks;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
leading: Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: CircleAvatar(
|
|
||||||
backgroundColor: colorScheme.primaryContainer,
|
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onPrimaryContainer, size: 20),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: const Text('SpotiFLAC'),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.settings_outlined),
|
|
||||||
onPressed: () => context.push('/settings'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
||||||
child: TextField(
|
|
||||||
controller: _urlController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Paste Spotify URL or search...',
|
|
||||||
prefixIcon: const Icon(Icons.link),
|
|
||||||
suffixIcon: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard),
|
|
||||||
IconButton(icon: const Icon(Icons.search), onPressed: _fetchMetadata),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _fetchMetadata(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (trackState.error != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: Text(
|
|
||||||
trackState.error!,
|
|
||||||
style: TextStyle(color: colorScheme.error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (trackState.isLoading)
|
|
||||||
LinearProgressIndicator(color: colorScheme.primary),
|
|
||||||
|
|
||||||
if (trackState.albumName != null || trackState.playlistName != null)
|
|
||||||
_buildHeader(trackState, colorScheme),
|
|
||||||
|
|
||||||
if (tracks.length > 1)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed: _downloadAll,
|
|
||||||
icon: const Icon(Icons.download),
|
|
||||||
label: Text('Download All (${tracks.length})'),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
minimumSize: const Size.fromHeight(48),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
Expanded(
|
|
||||||
child: tracks.isEmpty
|
|
||||||
? _buildEmptyState(colorScheme)
|
|
||||||
: ListView.builder(
|
|
||||||
itemCount: tracks.length,
|
|
||||||
itemBuilder: (context, index) =>
|
|
||||||
_buildTrackTile(tracks[index], colorScheme),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
bottomNavigationBar: NavigationBar(
|
|
||||||
selectedIndex: _currentIndex,
|
|
||||||
onDestinationSelected: _onNavTap,
|
|
||||||
destinations: [
|
|
||||||
const NavigationDestination(
|
|
||||||
icon: Icon(Icons.search_outlined),
|
|
||||||
selectedIcon: Icon(Icons.search),
|
|
||||||
label: 'Search',
|
|
||||||
),
|
|
||||||
NavigationDestination(
|
|
||||||
icon: Badge(
|
|
||||||
isLabelVisible: queuedCount > 0,
|
|
||||||
label: Text('$queuedCount'),
|
|
||||||
child: const Icon(Icons.queue_music_outlined),
|
|
||||||
),
|
|
||||||
selectedIcon: Badge(
|
|
||||||
isLabelVisible: queuedCount > 0,
|
|
||||||
label: Text('$queuedCount'),
|
|
||||||
child: const Icon(Icons.queue_music),
|
|
||||||
),
|
|
||||||
label: 'Queue',
|
|
||||||
),
|
|
||||||
const NavigationDestination(
|
|
||||||
icon: Icon(Icons.history_outlined),
|
|
||||||
selectedIcon: Icon(Icons.history),
|
|
||||||
label: 'History',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHeader(TrackState state, ColorScheme colorScheme) {
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.all(16),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
if (state.coverUrl != null)
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: state.coverUrl!,
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
cacheManager: CoverCacheManager.instance,
|
|
||||||
placeholder: (_, _) => Container(
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
state.albumName ?? state.playlistName ?? '',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${state.tracks.length} tracks',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
FilledButton.tonal(
|
|
||||||
onPressed: _downloadAll,
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.download),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
|
|
||||||
final isCollection = track.isCollection;
|
|
||||||
|
|
||||||
String subtitleText;
|
|
||||||
if (isCollection) {
|
|
||||||
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
|
|
||||||
final capitalizedType = typeLabel.isNotEmpty
|
|
||||||
? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}'
|
|
||||||
: 'Album';
|
|
||||||
final year = track.releaseDate != null && track.releaseDate!.length >= 4
|
|
||||||
? track.releaseDate!.substring(0, 4)
|
|
||||||
: '';
|
|
||||||
subtitleText = '$capitalizedType • ${track.artistName}${year.isNotEmpty ? ' • $year' : ''}';
|
|
||||||
} else {
|
|
||||||
subtitleText = track.artistName;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: track.coverUrl != null
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: track.coverUrl!,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
cacheManager: CoverCacheManager.instance,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
isCollection ? Icons.album : Icons.music_note,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
||||||
subtitle: Text(
|
|
||||||
subtitleText,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
trailing: isCollection
|
|
||||||
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
|
|
||||||
: Text(
|
|
||||||
_formatDuration(track.duration),
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openCollection(Track track) async {
|
|
||||||
final extensionId = track.source;
|
|
||||||
if (extensionId == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (track.isAlbumItem) {
|
|
||||||
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
|
|
||||||
if (albumData != null && mounted) {
|
|
||||||
final trackList = albumData['tracks'] as List<dynamic>? ?? [];
|
|
||||||
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
|
|
||||||
ref.read(trackProvider.notifier).setTracksFromCollection(
|
|
||||||
tracks: tracks,
|
|
||||||
albumName: albumData['name'] as String? ?? track.name,
|
|
||||||
coverUrl: albumData['cover_url'] as String? ?? track.coverUrl,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (track.isPlaylistItem) {
|
|
||||||
final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id);
|
|
||||||
if (playlistData != null && mounted) {
|
|
||||||
final trackList = playlistData['tracks'] as List<dynamic>? ?? [];
|
|
||||||
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
|
|
||||||
ref.read(trackProvider.notifier).setTracksFromCollection(
|
|
||||||
tracks: tracks,
|
|
||||||
playlistName: playlistData['name'] as String? ?? track.name,
|
|
||||||
coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Failed to load: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Track _parseExtensionTrack(Map<String, dynamic> data, String source) {
|
|
||||||
int durationMs = 0;
|
|
||||||
final durationValue = data['duration_ms'];
|
|
||||||
if (durationValue is int) {
|
|
||||||
durationMs = durationValue;
|
|
||||||
} else if (durationValue is double) {
|
|
||||||
durationMs = durationValue.toInt();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Track(
|
|
||||||
id: (data['id'] ?? '').toString(),
|
|
||||||
name: (data['name'] ?? '').toString(),
|
|
||||||
artistName: (data['artists'] ?? '').toString(),
|
|
||||||
albumName: (data['album_name'] ?? '').toString(),
|
|
||||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
|
||||||
duration: (durationMs / 1000).round(),
|
|
||||||
releaseDate: data['release_date']?.toString(),
|
|
||||||
source: source,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDuration(int ms) {
|
|
||||||
if (ms == 0) return '';
|
|
||||||
final duration = Duration(milliseconds: ms);
|
|
||||||
final minutes = duration.inMinutes;
|
|
||||||
final seconds = duration.inSeconds % 60;
|
|
||||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState(ColorScheme colorScheme) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.music_note,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Paste a Spotify URL to get started',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/services/library_database.dart';
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
import 'package:spotiflac_android/services/palette_service.dart';
|
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
|
|
||||||
/// Screen to display tracks from a local library album
|
/// Screen to display tracks from a local library album
|
||||||
@@ -30,26 +30,19 @@ class LocalAlbumScreen extends ConsumerStatefulWidget {
|
|||||||
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||||
bool _isSelectionMode = false;
|
bool _isSelectionMode = false;
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
Color? _dominantColor;
|
|
||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
late List<LocalLibraryItem> _sortedTracksCache;
|
late List<LocalLibraryItem> _sortedTracksCache;
|
||||||
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
|
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
|
||||||
late List<int> _sortedDiscNumbersCache;
|
late List<int> _sortedDiscNumbersCache;
|
||||||
late bool _hasMultipleDiscsCache;
|
late bool _hasMultipleDiscsCache;
|
||||||
|
String? _commonQualityCache;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
_rebuildTrackCaches();
|
_rebuildTrackCaches();
|
||||||
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
|
|
||||||
if (cachedColor != null) {
|
|
||||||
_dominantColor = cachedColor;
|
|
||||||
}
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_extractDominantColor();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -59,13 +52,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
oldWidget.tracks.length != widget.tracks.length) {
|
oldWidget.tracks.length != widget.tracks.length) {
|
||||||
_rebuildTrackCaches();
|
_rebuildTrackCaches();
|
||||||
}
|
}
|
||||||
if (oldWidget.coverPath != widget.coverPath) {
|
|
||||||
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
|
|
||||||
if (cachedColor != null && cachedColor != _dominantColor) {
|
|
||||||
_dominantColor = cachedColor;
|
|
||||||
}
|
|
||||||
_extractDominantColor();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -82,18 +68,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _extractDominantColor() async {
|
|
||||||
if (widget.coverPath == null || widget.coverPath!.isEmpty) return;
|
|
||||||
|
|
||||||
// Extract color from local file
|
|
||||||
final color = await PaletteService.instance.extractDominantColorFromFile(widget.coverPath!);
|
|
||||||
if (mounted && color != null && color != _dominantColor) {
|
|
||||||
setState(() {
|
|
||||||
_dominantColor = color;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<LocalLibraryItem> _buildSortedTracks() {
|
List<LocalLibraryItem> _buildSortedTracks() {
|
||||||
final tracks = List<LocalLibraryItem>.from(widget.tracks);
|
final tracks = List<LocalLibraryItem>.from(widget.tracks);
|
||||||
tracks.sort((a, b) {
|
tracks.sort((a, b) {
|
||||||
@@ -114,9 +88,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
|
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
|
||||||
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
|
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
|
||||||
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
|
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
|
||||||
|
_commonQualityCache = _computeCommonQuality(_sortedTracksCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(List<LocalLibraryItem> tracks) {
|
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
|
||||||
|
List<LocalLibraryItem> tracks,
|
||||||
|
) {
|
||||||
final discMap = <int, List<LocalLibraryItem>>{};
|
final discMap = <int, List<LocalLibraryItem>>{};
|
||||||
for (final track in tracks) {
|
for (final track in tracks) {
|
||||||
final discNumber = track.discNumber ?? 1;
|
final discNumber = track.discNumber ?? 1;
|
||||||
@@ -185,15 +162,16 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
final libraryNotifier = ref.read(localLibraryProvider.notifier);
|
final libraryNotifier = ref.read(localLibraryProvider.notifier);
|
||||||
final idsToDelete = _selectedIds.toList();
|
final idsToDelete = _selectedIds.toList();
|
||||||
|
final tracksById = {for (final track in currentTracks) track.id: track};
|
||||||
|
|
||||||
int deletedCount = 0;
|
int deletedCount = 0;
|
||||||
for (final id in idsToDelete) {
|
for (final id in idsToDelete) {
|
||||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
final item = tracksById[id];
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
try {
|
try {
|
||||||
await deleteFile(item.filePath);
|
await deleteFile(item.filePath);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
libraryNotifier.removeItem(id);
|
await libraryNotifier.removeItem(id);
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +180,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
|
SnackBar(
|
||||||
|
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Go back if all tracks were deleted
|
// Go back if all tracks were deleted
|
||||||
@@ -219,7 +199,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
|
SnackBar(
|
||||||
|
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,12 +216,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
// Show empty state if no tracks found
|
// Show empty state if no tracks found
|
||||||
if (tracks.isEmpty) {
|
if (tracks.isEmpty) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: Text(widget.albumName)),
|
||||||
title: Text(widget.albumName),
|
body: const Center(child: Text('No tracks found for this album')),
|
||||||
),
|
|
||||||
body: const Center(
|
|
||||||
child: Text('No tracks found for this album'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +246,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
_buildInfoCard(context, colorScheme, tracks),
|
_buildInfoCard(context, colorScheme, tracks),
|
||||||
_buildTrackListHeader(context, colorScheme, tracks),
|
_buildTrackListHeader(context, colorScheme, tracks),
|
||||||
_buildTrackList(context, colorScheme, tracks),
|
_buildTrackList(context, colorScheme, tracks),
|
||||||
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
SliverToBoxAdapter(
|
||||||
|
child: SizedBox(height: _isSelectionMode ? 120 : 32),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -278,7 +258,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
||||||
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
|
child: _buildSelectionBottomBar(
|
||||||
|
context,
|
||||||
|
colorScheme,
|
||||||
|
tracks,
|
||||||
|
bottomPadding,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -287,12 +272,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final mediaSize = MediaQuery.of(context).size;
|
||||||
final coverSize = screenWidth * 0.5;
|
final screenWidth = mediaSize.width;
|
||||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
final shortestSide = mediaSize.shortestSide;
|
||||||
|
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
|
||||||
|
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
|
||||||
|
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||||
|
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||||
|
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: 320,
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
stretch: true,
|
stretch: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
@@ -313,7 +303,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
),
|
),
|
||||||
flexibleSpace: LayoutBuilder(
|
flexibleSpace: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
final collapseRatio =
|
||||||
|
(constraints.maxHeight - kToolbarHeight) /
|
||||||
|
(expandedHeight - kToolbarHeight);
|
||||||
final showContent = collapseRatio > 0.3;
|
final showContent = collapseRatio > 0.3;
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
@@ -321,19 +313,39 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Background with dominant color
|
// Blurred cover background
|
||||||
AnimatedContainer(
|
if (widget.coverPath != null)
|
||||||
duration: const Duration(milliseconds: 500),
|
Image.file(
|
||||||
decoration: BoxDecoration(
|
File(widget.coverPath!),
|
||||||
gradient: LinearGradient(
|
fit: BoxFit.cover,
|
||||||
begin: Alignment.topCenter,
|
errorBuilder: (_, _, _) =>
|
||||||
end: Alignment.bottomCenter,
|
Container(color: colorScheme.surface),
|
||||||
colors: [
|
)
|
||||||
bgColor,
|
else
|
||||||
bgColor.withValues(alpha: 0.8),
|
Container(color: colorScheme.surface),
|
||||||
colorScheme.surface,
|
ClipRect(
|
||||||
],
|
child: BackdropFilter(
|
||||||
stops: const [0.0, 0.6, 1.0],
|
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surface.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: bottomGradientHeight,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surface.withValues(alpha: 0.0),
|
||||||
|
colorScheme.surface,
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -343,7 +355,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
opacity: showContent ? 1.0 : 0.0,
|
opacity: showContent ? 1.0 : 0.0,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 60),
|
padding: EdgeInsets.only(top: coverTopPadding),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: coverSize,
|
width: coverSize,
|
||||||
height: coverSize,
|
height: coverSize,
|
||||||
@@ -366,13 +378,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
cacheWidth: (coverSize * 2).toInt(),
|
cacheWidth: (coverSize * 2).toInt(),
|
||||||
errorBuilder: (context, error, stackTrace) =>
|
errorBuilder: (context, error, stackTrace) =>
|
||||||
Container(
|
Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color:
|
||||||
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.album,
|
||||||
|
size: fallbackIconSize,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
child: Icon(
|
||||||
|
Icons.album,
|
||||||
|
size: fallbackIconSize,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -381,14 +402,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
stretchModes: const [
|
||||||
|
StretchMode.zoomBackground,
|
||||||
|
StretchMode.blurBackground,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: Container(
|
icon: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
@@ -396,14 +423,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
|
Widget _buildInfoCard(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
List<LocalLibraryItem> tracks,
|
||||||
|
) {
|
||||||
|
final commonQuality = _commonQualityCache;
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Card(
|
child: Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.surfaceContainerLow,
|
color: colorScheme.surfaceContainerLow,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -411,58 +446,100 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
widget.albumName,
|
widget.albumName,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
widget.artistName,
|
widget.artistName,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// "Local" badge
|
// "Local" badge
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.tertiaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.folder, size: 14, color: colorScheme.onTertiaryContainer),
|
Icon(
|
||||||
|
Icons.folder,
|
||||||
|
size: 14,
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text('Local', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
Text(
|
||||||
|
'Local',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// Track count
|
// Track count
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSurfaceVariant),
|
Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
size: 14,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12)),
|
Text(
|
||||||
|
'${tracks.length} tracks',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// Quality badge if all tracks have the same quality
|
// Quality badge if all tracks have the same quality
|
||||||
if (_getCommonQuality(tracks) != null)
|
if (commonQuality != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getCommonQuality(tracks)!.contains('24')
|
color: commonQuality.contains('24')
|
||||||
? colorScheme.primaryContainer
|
? colorScheme.primaryContainer
|
||||||
: colorScheme.surfaceContainerHighest,
|
: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_getCommonQuality(tracks)!,
|
commonQuality,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: _getCommonQuality(tracks)!.contains('24')
|
color: commonQuality.contains('24')
|
||||||
? colorScheme.onPrimaryContainer
|
? colorScheme.onPrimaryContainer
|
||||||
: colorScheme.onSurfaceVariant,
|
: colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -480,21 +557,41 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _getCommonQuality(List<LocalLibraryItem> tracks) {
|
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
|
||||||
if (tracks.isEmpty) return null;
|
if (tracks.isEmpty) return null;
|
||||||
final first = tracks.first;
|
final first = tracks.first;
|
||||||
if (first.bitDepth == null || first.sampleRate == null) return null;
|
|
||||||
|
|
||||||
final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
|
// 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';
|
||||||
for (final track in tracks) {
|
for (final track in tracks) {
|
||||||
if (track.bitDepth != first.bitDepth || track.sampleRate != first.sampleRate) {
|
if (track.bitDepth != first.bitDepth ||
|
||||||
|
track.sampleRate != first.sampleRate) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return firstQuality;
|
return firstQuality;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
|
Widget _buildTrackListHeader(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
List<LocalLibraryItem> tracks,
|
||||||
|
) {
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
@@ -502,14 +599,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
Text(
|
||||||
|
context.l10n.downloadedAlbumTracksHeader,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (!_isSelectionMode)
|
if (!_isSelectionMode)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
onPressed: tracks.isNotEmpty
|
||||||
|
? () => _enterSelectionMode(tracks.first.id)
|
||||||
|
: null,
|
||||||
icon: const Icon(Icons.checklist, size: 18),
|
icon: const Icon(Icons.checklist, size: 18),
|
||||||
label: Text(context.l10n.actionSelect),
|
label: Text(context.l10n.actionSelect),
|
||||||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
style: TextButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -517,7 +624,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
|
Widget _buildTrackList(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
List<LocalLibraryItem> tracks,
|
||||||
|
) {
|
||||||
final discGroups = _discGroupsCache;
|
final discGroups = _discGroupsCache;
|
||||||
final hasMultipleDiscs = _hasMultipleDiscsCache;
|
final hasMultipleDiscs = _hasMultipleDiscsCache;
|
||||||
|
|
||||||
@@ -534,7 +645,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.secondaryContainer,
|
color: colorScheme.secondaryContainer,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
@@ -542,14 +656,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
|
Icon(
|
||||||
|
Icons.album,
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.downloadedAlbumDiscHeader(discNumber),
|
context.l10n.downloadedAlbumDiscHeader(discNumber),
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
style: Theme.of(context).textTheme.labelLarge
|
||||||
color: colorScheme.onSecondaryContainer,
|
?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
color: colorScheme.onSecondaryContainer,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -571,7 +690,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
slivers.add(
|
slivers.add(
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) => _buildTrackItem(context, colorScheme, discTracks[index]),
|
(context, index) =>
|
||||||
|
_buildTrackItem(context, colorScheme, discTracks[index]),
|
||||||
childCount: discTracks.length,
|
childCount: discTracks.length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -581,21 +701,31 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return SliverMainAxisGroup(slivers: slivers);
|
return SliverMainAxisGroup(slivers: slivers);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, LocalLibraryItem track) {
|
Widget _buildTrackItem(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
LocalLibraryItem track,
|
||||||
|
) {
|
||||||
final isSelected = _selectedIds.contains(track.id);
|
final isSelected = _selectedIds.contains(track.id);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Card(
|
child: Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
|
color: isSelected
|
||||||
|
? colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||||
|
: Colors.transparent,
|
||||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
onTap: _isSelectionMode
|
onTap: _isSelectionMode
|
||||||
? () => _toggleSelection(track.id)
|
? () => _toggleSelection(track.id)
|
||||||
: () => _openFile(track.filePath),
|
: () => _openFile(track.filePath),
|
||||||
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
|
onLongPress: _isSelectionMode
|
||||||
|
? null
|
||||||
|
: () => _enterSelectionMode(track.id),
|
||||||
leading: Row(
|
leading: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -604,12 +734,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? colorScheme.primary : Colors.transparent,
|
color: isSelected
|
||||||
|
? colorScheme.primary
|
||||||
|
: Colors.transparent,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.outline,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: isSelected
|
child: isSelected
|
||||||
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
|
? Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
size: 16,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -631,7 +772,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
track.trackName,
|
track.trackName,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
subtitle: Row(
|
subtitle: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -644,27 +787,45 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (track.format != null) ...[
|
if (track.format != null) ...[
|
||||||
Text(' • ', style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12)),
|
Text(
|
||||||
|
' • ',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
track.format!.toUpperCase(),
|
track.format!.toUpperCase(),
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12),
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: _isSelectionMode ? null : IconButton(
|
trailing: _isSelectionMode
|
||||||
onPressed: () => _openFile(track.filePath),
|
? null
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
: IconButton(
|
||||||
style: IconButton.styleFrom(
|
onPressed: () => _openFile(track.filePath),
|
||||||
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||||
),
|
style: IconButton.styleFrom(
|
||||||
),
|
backgroundColor: colorScheme.primaryContainer.withValues(
|
||||||
|
alpha: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks, double bottomPadding) {
|
Widget _buildSelectionBottomBar(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
List<LocalLibraryItem> tracks,
|
||||||
|
double bottomPadding,
|
||||||
|
) {
|
||||||
final selectedCount = _selectedIds.length;
|
final selectedCount = _selectedIds.length;
|
||||||
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||||
|
|
||||||
@@ -711,12 +872,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
context.l10n.downloadedAlbumSelectedCount(
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
selectedCount,
|
||||||
|
),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
|
allSelected
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
? context.l10n.downloadedAlbumAllSelected
|
||||||
|
: context.l10n.downloadedAlbumTapToSelect,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -729,9 +896,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
_selectAll(tracks);
|
_selectAll(tracks);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
|
icon: Icon(
|
||||||
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
|
allSelected ? Icons.deselect : Icons.select_all,
|
||||||
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
|
size: 20,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
allSelected
|
||||||
|
? context.l10n.actionDeselect
|
||||||
|
: context.l10n.actionSelectAll,
|
||||||
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.primary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -739,7 +915,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
|
onPressed: selectedCount > 0
|
||||||
|
? () => _deleteSelected(tracks)
|
||||||
|
: null,
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
label: Text(
|
label: Text(
|
||||||
selectedCount > 0
|
selectedCount > 0
|
||||||
@@ -747,10 +925,16 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
: context.l10n.downloadedAlbumSelectToDelete,
|
: context.l10n.downloadedAlbumSelectToDelete,
|
||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
|
backgroundColor: selectedCount > 0
|
||||||
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
|
? colorScheme.error
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
foregroundColor: selectedCount > 0
|
||||||
|
? colorScheme.onError
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -181,10 +181,14 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
final treeUri = result['tree_uri'] as String? ?? '';
|
final treeUri = result['tree_uri'] as String? ?? '';
|
||||||
final displayName = result['display_name'] as String? ?? '';
|
final displayName = result['display_name'] as String? ?? '';
|
||||||
if (treeUri.isNotEmpty) {
|
if (treeUri.isNotEmpty) {
|
||||||
ref.read(settingsProvider.notifier).setDownloadTreeUri(
|
ref
|
||||||
treeUri,
|
.read(settingsProvider.notifier)
|
||||||
displayName: displayName.isNotEmpty ? displayName : treeUri,
|
.setDownloadTreeUri(
|
||||||
);
|
treeUri,
|
||||||
|
displayName: displayName.isNotEmpty
|
||||||
|
? displayName
|
||||||
|
: treeUri,
|
||||||
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -280,7 +284,16 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
final queueState = ref.watch(
|
final queueState = ref.watch(
|
||||||
downloadQueueProvider.select((s) => s.queuedCount),
|
downloadQueueProvider.select((s) => s.queuedCount),
|
||||||
);
|
);
|
||||||
final trackState = ref.watch(trackProvider);
|
final trackHasSearchText = ref.watch(
|
||||||
|
trackProvider.select((s) => s.hasSearchText),
|
||||||
|
);
|
||||||
|
final trackHasContent = ref.watch(
|
||||||
|
trackProvider.select((s) => s.hasContent),
|
||||||
|
);
|
||||||
|
final trackIsLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||||
|
final trackIsShowingRecentAccess = ref.watch(
|
||||||
|
trackProvider.select((s) => s.isShowingRecentAccess),
|
||||||
|
);
|
||||||
final showStore = ref.watch(
|
final showStore = ref.watch(
|
||||||
settingsProvider.select((s) => s.showExtensionStore),
|
settingsProvider.select((s) => s.showExtensionStore),
|
||||||
);
|
);
|
||||||
@@ -292,10 +305,10 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
|
|
||||||
final canPop =
|
final canPop =
|
||||||
_currentIndex == 0 &&
|
_currentIndex == 0 &&
|
||||||
!trackState.hasSearchText &&
|
!trackHasSearchText &&
|
||||||
!trackState.hasContent &&
|
!trackHasContent &&
|
||||||
!trackState.isLoading &&
|
!trackIsLoading &&
|
||||||
!trackState.isShowingRecentAccess &&
|
!trackIsShowingRecentAccess &&
|
||||||
!isKeyboardVisible;
|
!isKeyboardVisible;
|
||||||
|
|
||||||
final tabs = <Widget>[
|
final tabs = <Widget>[
|
||||||
@@ -377,7 +390,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
body: PageView(
|
body: PageView(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
onPageChanged: _onPageChanged,
|
onPageChanged: _onPageChanged,
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: (_currentIndex == 0 && trackIsShowingRecentAccess)
|
||||||
|
? const _NoSwipeRightPhysics()
|
||||||
|
: const ClampingScrollPhysics(),
|
||||||
children: tabs,
|
children: tabs,
|
||||||
),
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
@@ -400,6 +415,26 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Custom physics that blocks swiping to the right (next page) while
|
||||||
|
/// still allowing vertical scrolling inside the page content.
|
||||||
|
class _NoSwipeRightPhysics extends ScrollPhysics {
|
||||||
|
const _NoSwipeRightPhysics({super.parent});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_NoSwipeRightPhysics applyTo(ScrollPhysics? ancestor) {
|
||||||
|
return _NoSwipeRightPhysics(parent: buildParent(ancestor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
|
||||||
|
// In a horizontal PageView, a negative offset means the user is
|
||||||
|
// dragging left (i.e. trying to go to the next page / right).
|
||||||
|
// Block that direction only.
|
||||||
|
if (offset < 0) return 0.0;
|
||||||
|
return super.applyPhysicsToUserOffset(position, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class BouncingIcon extends StatefulWidget {
|
class BouncingIcon extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
const BouncingIcon({super.key, required this.child});
|
const BouncingIcon({super.key, required this.child});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:spotiflac_android/services/palette_service.dart';
|
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
@@ -32,7 +33,6 @@ class PlaylistScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||||
Color? _dominantColor;
|
|
||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
List<Track>? _fetchedTracks;
|
List<Track>? _fetchedTracks;
|
||||||
@@ -45,7 +45,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
_extractDominantColor();
|
|
||||||
_fetchTracksIfNeeded();
|
_fetchTracksIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +70,17 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
playlistId = playlistId.substring(7);
|
playlistId = playlistId.substring(7);
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
|
final result = await PlatformBridge.getDeezerMetadata(
|
||||||
|
'playlist',
|
||||||
|
playlistId,
|
||||||
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// Go backend returns 'track_list' not 'tracks'
|
// Go backend returns 'track_list' not 'tracks'
|
||||||
final trackList = result['track_list'] as List<dynamic>? ?? [];
|
final trackList = result['track_list'] as List<dynamic>? ?? [];
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_fetchedTracks = tracks;
|
_fetchedTracks = tracks;
|
||||||
@@ -122,14 +126,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _extractDominantColor() async {
|
|
||||||
if (widget.coverUrl == null) return;
|
|
||||||
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
|
|
||||||
if (mounted && color != null) {
|
|
||||||
setState(() => _dominantColor = color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -149,15 +145,21 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final mediaSize = MediaQuery.of(context).size;
|
||||||
final coverSize = screenWidth * 0.5; // 50% of screen width
|
final screenWidth = mediaSize.width;
|
||||||
final bgColor = _dominantColor ?? colorScheme.surface;
|
final shortestSide = mediaSize.shortestSide;
|
||||||
|
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
|
||||||
|
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
|
||||||
|
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||||
|
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||||
|
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: 320,
|
expandedHeight: expandedHeight,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
stretch: true,
|
stretch: true,
|
||||||
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
|
backgroundColor:
|
||||||
|
colorScheme.surface, // Use theme color for collapsed state
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
title: AnimatedOpacity(
|
title: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
@@ -175,27 +177,60 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
),
|
),
|
||||||
flexibleSpace: LayoutBuilder(
|
flexibleSpace: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
final collapseRatio =
|
||||||
|
(constraints.maxHeight - kToolbarHeight) /
|
||||||
|
(expandedHeight - kToolbarHeight);
|
||||||
final showContent = collapseRatio > 0.3;
|
final showContent = collapseRatio > 0.3;
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(
|
||||||
|
context,
|
||||||
|
).clamp(1.0, 3.0).toDouble();
|
||||||
|
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
|
||||||
|
.round()
|
||||||
|
.clamp(720, 1440)
|
||||||
|
.toInt();
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
collapseMode: CollapseMode.none,
|
collapseMode: CollapseMode.none,
|
||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
// Background with dominant color
|
// Blurred cover background
|
||||||
AnimatedContainer(
|
if (widget.coverUrl != null)
|
||||||
duration: const Duration(milliseconds: 500),
|
CachedNetworkImage(
|
||||||
decoration: BoxDecoration(
|
imageUrl: widget.coverUrl!,
|
||||||
gradient: LinearGradient(
|
fit: BoxFit.cover,
|
||||||
begin: Alignment.topCenter,
|
memCacheWidth: backgroundMemCacheWidth,
|
||||||
end: Alignment.bottomCenter,
|
cacheManager: CoverCacheManager.instance,
|
||||||
colors: [
|
placeholder: (_, _) =>
|
||||||
bgColor,
|
Container(color: colorScheme.surface),
|
||||||
bgColor.withValues(alpha: 0.8),
|
errorWidget: (_, _, _) =>
|
||||||
colorScheme.surface,
|
Container(color: colorScheme.surface),
|
||||||
],
|
)
|
||||||
stops: const [0.0, 0.6, 1.0],
|
else
|
||||||
|
Container(color: colorScheme.surface),
|
||||||
|
ClipRect(
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surface.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: bottomGradientHeight,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surface.withValues(alpha: 0.0),
|
||||||
|
colorScheme.surface,
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -205,7 +240,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
opacity: showContent ? 1.0 : 0.0,
|
opacity: showContent ? 1.0 : 0.0,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 60),
|
padding: EdgeInsets.only(top: coverTopPadding),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: coverSize,
|
width: coverSize,
|
||||||
height: coverSize,
|
height: coverSize,
|
||||||
@@ -222,7 +257,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: widget.coverUrl != null
|
child: widget.coverUrl != null
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: widget.coverUrl!,
|
imageUrl: widget.coverUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: (coverSize * 2).toInt(),
|
memCacheWidth: (coverSize * 2).toInt(),
|
||||||
@@ -230,7 +265,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: Icon(Icons.playlist_play, size: 64, color: colorScheme.onSurfaceVariant),
|
child: Icon(
|
||||||
|
Icons.playlist_play,
|
||||||
|
size: fallbackIconSize,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -239,7 +278,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
stretchModes: const [
|
||||||
|
StretchMode.zoomBackground,
|
||||||
|
StretchMode.blurBackground,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -264,34 +306,63 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
child: Card(
|
child: Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.surfaceContainerLow,
|
color: colorScheme.surfaceContainerLow,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(widget.playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
Text(
|
||||||
|
widget.playlistName,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.tertiaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
Icon(
|
||||||
|
Icons.playlist_play,
|
||||||
|
size: 14,
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(context.l10n.tracksCount(_tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
Text(
|
||||||
|
context.l10n.tracksCount(_tracks.length),
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _tracks.isEmpty ? null : () => _downloadAll(context),
|
onPressed: _tracks.isEmpty
|
||||||
|
? null
|
||||||
|
: () => _downloadAll(context),
|
||||||
icon: const Icon(Icons.download, size: 18),
|
icon: const Icon(Icons.download, size: 18),
|
||||||
label: Text(context.l10n.downloadAllCount(_tracks.length)),
|
label: Text(context.l10n.downloadAllCount(_tracks.length)),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(48),
|
minimumSize: const Size.fromHeight(48),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -310,7 +381,13 @@ const SizedBox(height: 16),
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
Text(
|
||||||
|
context.l10n.tracksHeader,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -339,7 +416,12 @@ const SizedBox(height: 16),
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.error_outline, color: colorScheme.error),
|
Icon(Icons.error_outline, color: colorScheme.error),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(child: Text(_error!, style: TextStyle(color: colorScheme.error))),
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(color: colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -363,19 +445,16 @@ const SizedBox(height: 16),
|
|||||||
}
|
}
|
||||||
|
|
||||||
return SliverList(
|
return SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
(context, index) {
|
final track = _tracks[index];
|
||||||
final track = _tracks[index];
|
return KeyedSubtree(
|
||||||
return KeyedSubtree(
|
key: ValueKey(track.id),
|
||||||
key: ValueKey(track.id),
|
child: _PlaylistTrackItem(
|
||||||
child: _PlaylistTrackItem(
|
track: track,
|
||||||
track: track,
|
onDownload: () => _downloadTrack(context, track),
|
||||||
onDownload: () => _downloadTrack(context, track),
|
),
|
||||||
),
|
);
|
||||||
);
|
}, childCount: _tracks.length),
|
||||||
},
|
|
||||||
childCount: _tracks.length,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,13 +467,23 @@ const SizedBox(height: 16),
|
|||||||
artistName: track.artistName,
|
artistName: track.artistName,
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
ref
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
|
.read(downloadQueueProvider.notifier)
|
||||||
|
.addToQueue(track, service, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
ref
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
|
.read(downloadQueueProvider.notifier)
|
||||||
|
.addToQueue(track, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,13 +496,29 @@ const SizedBox(height: 16),
|
|||||||
trackName: '${_tracks.length} tracks',
|
trackName: '${_tracks.length} tracks',
|
||||||
artistName: widget.playlistName,
|
artistName: widget.playlistName,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, service, qualityOverride: quality);
|
ref
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length))));
|
.read(downloadQueueProvider.notifier)
|
||||||
|
.addMultipleToQueue(_tracks, service, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, settings.defaultService);
|
ref
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length))));
|
.read(downloadQueueProvider.notifier)
|
||||||
|
.addMultipleToQueue(_tracks, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -430,23 +535,33 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
final queueItem = ref.watch(
|
final queueItem = ref.watch(
|
||||||
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
|
downloadQueueLookupProvider.select(
|
||||||
|
(lookup) => lookup.byTrackId[track.id],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
final isInHistory = ref.watch(
|
||||||
return state.isDownloaded(track.id);
|
downloadHistoryProvider.select((state) {
|
||||||
}));
|
return state.isDownloaded(track.id);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Check local library for duplicate detection
|
// Check local library for duplicate detection
|
||||||
final settings = ref.watch(settingsProvider);
|
final showLocalLibraryIndicator = ref.watch(
|
||||||
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
|
settingsProvider.select(
|
||||||
|
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
||||||
|
),
|
||||||
|
);
|
||||||
final isInLocalLibrary = showLocalLibraryIndicator
|
final isInLocalLibrary = showLocalLibraryIndicator
|
||||||
? ref.watch(localLibraryProvider.select((state) =>
|
? ref.watch(
|
||||||
state.existsInLibrary(
|
localLibraryProvider.select(
|
||||||
isrc: track.isrc,
|
(state) => state.existsInLibrary(
|
||||||
trackName: track.name,
|
isrc: track.isrc,
|
||||||
artistName: track.artistName,
|
trackName: track.name,
|
||||||
)))
|
artistName: track.artistName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
final isQueued = queueItem != null;
|
final isQueued = queueItem != null;
|
||||||
@@ -455,7 +570,8 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
|||||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
final progress = queueItem?.progress ?? 0.0;
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
final showAsDownloaded =
|
||||||
|
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
@@ -464,18 +580,58 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(
|
||||||
leading: track.coverUrl != null
|
borderRadius: BorderRadius.circular(12),
|
||||||
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance))
|
),
|
||||||
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
leading: track.coverUrl != null
|
||||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: track.coverUrl!,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 96,
|
||||||
|
cacheManager: CoverCacheManager.instance,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
track.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
subtitle: Row(
|
subtitle: Row(
|
||||||
children: [
|
children: [
|
||||||
Flexible(child: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant))),
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
track.artistName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (isInLocalLibrary) ...[
|
if (isInLocalLibrary) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.tertiaryContainer,
|
color: colorScheme.tertiaryContainer,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
@@ -483,43 +639,91 @@ leading: track.coverUrl != null
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer),
|
Icon(
|
||||||
|
Icons.folder_outlined,
|
||||||
|
size: 10,
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
const SizedBox(width: 3),
|
const SizedBox(width: 3),
|
||||||
Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)),
|
Text(
|
||||||
|
context.l10n.libraryInLibrary,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress),
|
trailing: _buildDownloadButton(
|
||||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
|
context,
|
||||||
|
ref,
|
||||||
|
colorScheme,
|
||||||
|
isQueued: isQueued,
|
||||||
|
isDownloading: isDownloading,
|
||||||
|
isFinalizing: isFinalizing,
|
||||||
|
showAsDownloaded: showAsDownloaded,
|
||||||
|
isInHistory: isInHistory,
|
||||||
|
isInLocalLibrary: isInLocalLibrary,
|
||||||
|
progress: progress,
|
||||||
|
),
|
||||||
|
onTap: () => _handleTap(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
isQueued: isQueued,
|
||||||
|
isInHistory: isInHistory,
|
||||||
|
isInLocalLibrary: isInLocalLibrary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async {
|
void _handleTap(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref, {
|
||||||
|
required bool isQueued,
|
||||||
|
required bool isInHistory,
|
||||||
|
required bool isInLocalLibrary,
|
||||||
|
}) async {
|
||||||
if (isQueued) return;
|
if (isQueued) return;
|
||||||
|
|
||||||
if (isInLocalLibrary) {
|
if (isInLocalLibrary) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))));
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInHistory) {
|
if (isInHistory) {
|
||||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
final historyItem = ref
|
||||||
|
.read(downloadHistoryProvider.notifier)
|
||||||
|
.getBySpotifyId(track.id);
|
||||||
if (historyItem != null) {
|
if (historyItem != null) {
|
||||||
final exists = await fileExists(historyItem.filePath);
|
final exists = await fileExists(historyItem.filePath);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.snackbarAlreadyDownloaded(track.name),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
ref
|
||||||
|
.read(downloadHistoryProvider.notifier)
|
||||||
|
.removeBySpotifyId(track.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -527,7 +731,10 @@ leading: track.coverUrl != null
|
|||||||
onDownload();
|
onDownload();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
|
Widget _buildDownloadButton(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ColorScheme colorScheme, {
|
||||||
required bool isQueued,
|
required bool isQueued,
|
||||||
required bool isDownloading,
|
required bool isDownloading,
|
||||||
required bool isFinalizing,
|
required bool isFinalizing,
|
||||||
@@ -541,8 +748,26 @@ leading: track.coverUrl != null
|
|||||||
|
|
||||||
if (showAsDownloaded) {
|
if (showAsDownloaded) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
|
onTap: () => _handleTap(
|
||||||
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
context,
|
||||||
|
ref,
|
||||||
|
isQueued: isQueued,
|
||||||
|
isInHistory: isInHistory,
|
||||||
|
isInLocalLibrary: isInLocalLibrary,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
size: iconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else if (isFinalizing) {
|
} else if (isFinalizing) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@@ -551,7 +776,11 @@ leading: track.coverUrl != null
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
|
CircularProgressIndicator(
|
||||||
|
strokeWidth: 3,
|
||||||
|
color: colorScheme.tertiary,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -563,17 +792,54 @@ leading: track.coverUrl != null
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
|
CircularProgressIndicator(
|
||||||
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
value: progress > 0 ? progress : null,
|
||||||
|
strokeWidth: 3,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
if (progress > 0)
|
||||||
|
Text(
|
||||||
|
'${(progress * 100).toInt()}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (isQueued) {
|
} else if (isQueued) {
|
||||||
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.hourglass_empty,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: iconSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onDownload,
|
onTap: onDownload,
|
||||||
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.secondaryContainer,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.download,
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
size: iconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|
||||||
|
|
||||||
class QueueScreen extends ConsumerWidget {
|
|
||||||
const QueueScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(context.l10n.queueTitle),
|
|
||||||
actions: [
|
|
||||||
if (items.isNotEmpty)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete_sweep),
|
|
||||||
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
|
|
||||||
tooltip: context.l10n.queueClearCompleted,
|
|
||||||
),
|
|
||||||
if (items.isNotEmpty)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.clear_all),
|
|
||||||
onPressed: () => _showClearAllDialog(context, ref),
|
|
||||||
tooltip: context.l10n.queueClearAll,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: items.isEmpty
|
|
||||||
? _buildEmptyState(context, colorScheme)
|
|
||||||
: ListView.builder(
|
|
||||||
itemCount: items.length,
|
|
||||||
itemBuilder: (context, index) =>
|
|
||||||
_buildQueueItem(context, ref, items[index], colorScheme),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.queue,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
context.l10n.queueEmpty,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
context.l10n.queueEmptySubtitle,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
|
|
||||||
return ListTile(
|
|
||||||
leading: item.track.coverUrl != null
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.track.coverUrl!,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
cacheManager: CoverCacheManager.instance,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
title: Text(item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.track.artistName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
if (item.status == DownloadStatus.downloading) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: item.progress > 0 ? item.progress : null,
|
|
||||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'${(item.progress * 100).toStringAsFixed(0)}%',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: _buildStatusIcon(context, item, colorScheme),
|
|
||||||
onTap: item.status == DownloadStatus.queued
|
|
||||||
? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
|
|
||||||
switch (item.status) {
|
|
||||||
case DownloadStatus.queued:
|
|
||||||
return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant);
|
|
||||||
case DownloadStatus.downloading:
|
|
||||||
return SizedBox(
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
value: item.progress,
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case DownloadStatus.finalizing:
|
|
||||||
return SizedBox(
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(strokeWidth: 2, color: colorScheme.tertiary),
|
|
||||||
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 12),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case DownloadStatus.completed:
|
|
||||||
return Icon(Icons.check_circle, color: colorScheme.primary);
|
|
||||||
case DownloadStatus.failed:
|
|
||||||
return IconButton(
|
|
||||||
icon: Icon(Icons.error, color: colorScheme.error),
|
|
||||||
onPressed: () => _showErrorDialog(context, item, colorScheme),
|
|
||||||
tooltip: 'Tap to see error details',
|
|
||||||
);
|
|
||||||
case DownloadStatus.skipped:
|
|
||||||
return Icon(Icons.skip_next, color: colorScheme.primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.error, color: colorScheme.error),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(context.l10n.queueDownloadFailed),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
content: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(context.l10n.queueErrorLabel, style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.errorContainer,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
item.error ?? context.l10n.queueUnknownError,
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 12,
|
|
||||||
color: colorScheme.onErrorContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: Text(context.l10n.dialogClose),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showClearAllDialog(BuildContext context, WidgetRef ref) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: Text(context.l10n.queueClearAll),
|
|
||||||
content: Text(context.l10n.queueClearAllMessage),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: Text(context.l10n.dialogCancel),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadQueueProvider.notifier).clearAll();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||